m365_active_storage 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 248f6e95435a9737f6aa06db40f6d758a59a4d6355ad4399a12d5dbc0d24efde
4
+ data.tar.gz: c93ea5f7c16c419e2cfadbc9ce6a679b1a0abb533bb9863b9a75f8b7aed7d17d
5
+ SHA512:
6
+ metadata.gz: 91d7ddd980e2959d4c834847affd9b515f1544c307fa00d562bf6c15b3754fa3c1d657ed648c03eb285eb7cd3a98daf75dc23c5c888bc88d1914fc19cd249528
7
+ data.tar.gz: d8cedd464a2fb9946f778b79b3113e1176aa4d444d56b271bfda1378a386a3765c21850279325a78b3af1a3c5547b5f85a3fe42857652e9b002e3d814cace891
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.8
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-01-13
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,19 @@
1
+ GNU GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (c) 2026 Óscar León
5
+
6
+ This program is free software: you can redistribute it and/or modify
7
+ it under the terms of the GNU General Public License as published by
8
+ the Free Software Foundation, either version 3 of the License, or
9
+ (at your option) any later version.
10
+
11
+ This program is distributed in the hope that it will be useful,
12
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ GNU General Public License for more details.
15
+
16
+ You should have received a copy of the GNU General Public License
17
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+
19
+ Full license text available at: https://www.gnu.org/licenses/gpl-3.0.txt
data/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # m365-active-storage
2
+
3
+ Rails ActiveStorage in M365 Sharepoint
4
+
5
+ ## Install the gem
6
+
7
+ ```ruby
8
+ gem install m365_active_storage
9
+ ```
10
+ or add to the Gemfile:
11
+
12
+ ```ruby
13
+ gem "m365_active_storage"
14
+ ```
15
+
16
+ ## Configure ActiveStorage
17
+
18
+ ### Configure Active Storage and auth
19
+ #### Rails credentials
20
+ ```yaml
21
+ sharepoint:
22
+ ms_graph_url:
23
+ ms_graph_version:
24
+ auth_host:
25
+ oauth_tenant:
26
+ oauth_app_id:
27
+ oauth_secret:
28
+ sharepoint_site_id:
29
+ sharepoint_drive_id:
30
+ ```
31
+ #### -- or --
32
+
33
+ #### ENV
34
+ ```shell
35
+ MS_GRAPH_URL=
36
+ MS_GRAPH_VERSION=
37
+ AUTH_HOST=
38
+ OAUTH_TENANT=
39
+ OAUTH_APP_ID=
40
+ OAUTH_SECRET=
41
+ SHAREPOINT_SITE_ID=
42
+ SHAREPOINT_DRIVE_ID=
43
+ ```
44
+
45
+ ### Set active storage to sharepoint service
46
+ In the app config/environments/`<environment>`.rb
47
+
48
+ ```ruby
49
+ config.active_storage.service = :sharepoint
50
+ ```
51
+
52
+ ### Run the check generator
53
+ ```
54
+ $ rails g m365_active_storage:check
55
+ ```
56
+
57
+ ## Move files from local to sharepoint with the migrate generator
58
+ ```shell
59
+ g m365_active_storage:migrate
60
+ ```
61
+
62
+ ```shell
63
+ 5 blobs to migrate
64
+ filename_1 done
65
+ filename_2 done
66
+ filename_3 done
67
+ filename_4 failed: ActiveStorage::FileNotFoundError
68
+ filename_5 done
69
+ ...
70
+ ```
71
+
72
+ > [!NOTE]
73
+ >
74
+ > The local files are still in the storage folder.
75
+ >
76
+ > You must delete them manually after validating the data has been correctly moved.
77
+ >
78
+
79
+ > [!NOTE]
80
+ >
81
+ > Don't forget to restart your server, if running
82
+ >
data/Rakefile ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+ require "rdoc/task"
6
+
7
+ Minitest::TestTask.create do |t|
8
+ t.framework = %(require "test/test_helper.rb")
9
+ t.test_globs = ["test/**/*_test.rb"]
10
+ end
11
+
12
+ require "rubocop/rake_task"
13
+
14
+ RuboCop::RakeTask.new
15
+
16
+ # RDoc task for generating API documentation
17
+ RDoc::Task.new do |rdoc|
18
+ rdoc.title = "M365 Active Storage - SharePoint Storage for Rails"
19
+ rdoc.main = "README.md"
20
+ rdoc.rdoc_files.include("README.md", "CHANGELOG.md", "LICENSE.txt", "lib/**/*.rb")
21
+ rdoc.rdoc_files.exclude("test/**/*", "spec/**/*")
22
+ rdoc.options = [
23
+ "--markup=markdown",
24
+ "--line-numbers",
25
+ "--all",
26
+ "--hyperlink-all"
27
+ ]
28
+ rdoc.rdoc_dir = "doc"
29
+ end
30
+
31
+ task default: %i[test rubocop]
32
+
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ Rails.application.routes.draw do
2
+ # Override default Active Storage blob route to use our authenticated controller
3
+ # This must come BEFORE the default activeStorage routes
4
+ get "/rails/active_storage/blobs/:signed_id/:filename", to: "m365_active_storage/blobs#show"
5
+ end
@@ -0,0 +1,10 @@
1
+ sharepoint:
2
+ service: Sharepoint
3
+ ms_graph_url: <%= Rails.application.credentials.dig(:sharepoint, :ms_graph_url) || ENV["MS_GRAPH_URL"] %>
4
+ ms_graph_version: <%= Rails.application.credentials.dig(:sharepoint, :ms_graph_version) || ENV["MS_GRAPH_VERSION"] %>
5
+ auth_host: <%= Rails.application.credentials.dig(:sharepoint, :auth_host) || ENV["AUTH_HOST"] %>
6
+ tenant_id: <%= Rails.application.credentials.dig(:sharepoint, :oauth_tenant) || ENV["OAUTH_TENANT"] %>
7
+ app_id: <%= Rails.application.credentials.dig(:sharepoint, :oauth_app_id) || ENV["OAUTH_APP_ID"] %>
8
+ secret: <%= Rails.application.credentials.dig(:sharepoint, :oauth_secret) || ENV["OAUTH_SECRET"] %>
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"] %>
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M365ActiveStorage
4
+ # == OAuth2 Authentication Handler
5
+ #
6
+ # Manages OAuth2 authentication with Microsoft Azure AD to obtain and maintain
7
+ # access tokens for Microsoft Graph API calls.
8
+ #
9
+ # === Responsibilities
10
+ #
11
+ # * Obtain OAuth2 access tokens using client credentials flow
12
+ # * Cache tokens and automatically refresh when expired
13
+ # * Handle authentication errors and retries
14
+ # * Manage token lifecycle and expiration
15
+ #
16
+ # === Architecture
17
+ #
18
+ # The Authentication class implements the OAuth2 Client Credentials flow:
19
+ # 1. Exchanges client ID and secret for an access token
20
+ # 2. Caches the token with its expiration time
21
+ # 3. Automatically refreshes tokens before expiration
22
+ # 4. Provides token to HTTP requests for API calls
23
+ #
24
+ # === Example Usage
25
+ #
26
+ # config = M365ActiveStorage::Configuration.new(**config_params)
27
+ # auth = M365ActiveStorage::Authentication.new(config)
28
+ #
29
+ # # Ensure we have a valid token before making API calls
30
+ # auth.ensure_valid_token
31
+ #
32
+ # # Token is now available for HTTP requests
33
+ # token = auth.token
34
+ #
35
+ # @attr_reader [Configuration] config The SharePoint configuration
36
+ # @attr_reader [String] token The current OAuth2 access token
37
+ # @attr_reader [Time] token_expires_at The expiration time of the current token
38
+ #
39
+ # @see M365ActiveStorage::Configuration
40
+ # @see M365ActiveStorage::Http
41
+ class Authentication
42
+ attr_reader :config, :token, :token_expires_at
43
+
44
+ # Initialize the Authentication handler
45
+ #
46
+ # @param [Configuration] config The SharePoint configuration object containing
47
+ # authentication parameters (auth_host, tenant_id, app_id, secret)
48
+ def initialize(config)
49
+ @config = config
50
+ @token = nil
51
+ @token_expires_at = nil
52
+ end
53
+
54
+ # Ensure a valid, non-expired token is available
55
+ #
56
+ # Checks if the current token is nil or expired. If so, obtains a new token
57
+ # from the Azure AD authentication endpoint. This method is called automatically
58
+ # before making API requests.
59
+ #
60
+ # If a valid token already exists and hasn't expired, this method returns immediately.
61
+ #
62
+ # @return [void]
63
+ # @raise [StandardError] if token retrieval fails
64
+ # @example
65
+ # auth.ensure_valid_token # Obtains token if needed
66
+ # puts auth.token # Token is now available
67
+ #
68
+ # @see #token_expired?
69
+ def ensure_valid_token
70
+ return unless token.blank? || token_expired?
71
+
72
+ obtain_app_token
73
+ end
74
+
75
+ # Force immediate token expiration
76
+ #
77
+ # Manually expires the current token by setting the expiration time to the past.
78
+ # This is useful for testing or forcing a token refresh.
79
+ #
80
+ # @return [Time] The new expiration time (1 minute in the past)
81
+ # @example
82
+ # auth.expire_token!
83
+ # auth.ensure_valid_token # Will fetch a new token
84
+ #
85
+ # @see #ensure_valid_token
86
+ def expire_token!
87
+ @token_expires_at = Time.current - 1.minute
88
+ end
89
+
90
+ private
91
+
92
+ # Build the authentication URL for Azure AD
93
+ #
94
+ # Constructs the OAuth2 token endpoint URL from the configured auth host and tenant ID.
95
+ #
96
+ # @return [String] The complete authentication URL
97
+ # @example
98
+ # # Returns: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token"
99
+ def auth_url
100
+ "#{config.auth_host}/#{config.tenant_id}/oauth2/v2.0/token"
101
+ end
102
+
103
+ # Parse the authentication URL into a URI object
104
+ #
105
+ # @return [URI] The parsed URI for the authentication endpoint
106
+ def auth_uri
107
+ URI(auth_url)
108
+ end
109
+
110
+ # Build the OAuth2 request body
111
+ #
112
+ # Constructs the form-encoded request body for the client credentials flow,
113
+ # including:
114
+ # * Grant type: "client_credentials"
115
+ # * Client ID from configuration
116
+ # * Client secret from configuration
117
+ # * Scope: Microsoft Graph API default scope
118
+ #
119
+ # @return [String] URL-encoded form data
120
+ # @example
121
+ # # Returns: "grant_type=client_credentials&client_id=...&client_secret=...&scope=..."
122
+ def request_body
123
+ URI.encode_www_form(
124
+ grant_type: "client_credentials",
125
+ client_id: config.app_id,
126
+ client_secret: config.secret,
127
+ scope: "#{config.ms_graph_url}/.default"
128
+ )
129
+ end
130
+
131
+ # Obtain a new OAuth2 application token
132
+ #
133
+ # Makes an HTTP request to the Azure AD token endpoint using client credentials.
134
+ # Parses the response and updates the token and expiration time.
135
+ #
136
+ # @return [void]
137
+ # @raise [StandardError] if the token request fails (non-200 response)
138
+ # @example
139
+ # # Automatically called by ensure_valid_token
140
+ # auth.send(:obtain_app_token)
141
+ #
142
+ # @see #fetch_token_response
143
+ # @see #parse_token_response
144
+ def obtain_app_token
145
+ response = fetch_token_response
146
+ raise "Failed to obtain SharePoint token" unless response.code.to_i == 200
147
+
148
+ parse_token_response(response)
149
+ end
150
+
151
+ # Fetch the token response from Azure AD
152
+ #
153
+ # Makes an HTTPS POST request to the Azure AD OAuth2 token endpoint
154
+ # with client credentials.
155
+ #
156
+ # @return [Net::HTTPResponse] The response from the token endpoint
157
+ # @raise [StandardError] if the HTTP request fails
158
+ def fetch_token_response
159
+ http = Net::HTTP.new(auth_uri.host, auth_uri.port)
160
+ http.use_ssl = true
161
+ http.post(auth_uri.request_uri, request_body, { "Content-Type" => "application/x-www-form-urlencoded" })
162
+ end
163
+
164
+ # Parse and store the token response from Azure AD
165
+ #
166
+ # Extracts the access token and expiration time from the JSON response.
167
+ # Automatically subtracts 5 minutes from the expiration time as a safety margin
168
+ # to prevent using nearly-expired tokens.
169
+ #
170
+ # @param [Net::HTTPResponse] response The HTTP response from the token endpoint
171
+ # @return [void]
172
+ # @example
173
+ # response = fetch_token_response
174
+ # parse_token_response(response) # Sets @token and @token_expires_at
175
+ def parse_token_response(response)
176
+ token_data = JSON.parse(response.body)
177
+ expires_in = token_data["expires_in"].to_i
178
+ @token = token_data["access_token"]
179
+ @token_expires_at = Time.current + expires_in.seconds - 5.minutes
180
+ end
181
+
182
+ # Check if the current token is expired
183
+ #
184
+ # @return [Boolean] true if the token is nil or the expiration time has passed
185
+ def token_expired?
186
+ token_expires_at.nil? || Time.current >= token_expires_at
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M365ActiveStorage
4
+ # == SharePoint Configuration Manager
5
+ #
6
+ # Manages and validates all configuration parameters needed to connect to Microsoft 365 SharePoint
7
+ # via the Microsoft Graph API.
8
+ #
9
+ # === Responsibilities
10
+ #
11
+ # * Load and parse configuration parameters
12
+ # * Validate that all required parameters are present
13
+ # * Provide convenient accessors for configuration values
14
+ # * Raise informative errors for missing or invalid configurations
15
+ #
16
+ # === Required Configuration Parameters
17
+ #
18
+ # * +ms_graph_url+ - The Microsoft Graph API base URL (typically https://graph.microsoft.com)
19
+ # * +ms_graph_version+ - The Graph API version (e.g., "v1.0", "beta")
20
+ # * +auth_host+ - The OAuth2 authentication endpoint (typically https://login.microsoftonline.com)
21
+ # * +tenant_id+ - Your Azure AD tenant ID (directory ID from Azure Portal)
22
+ # * +app_id+ - Your Azure AD application ID (client ID from your app registration)
23
+ # * +secret+ - Your Azure AD client secret
24
+ # * +site_id+ - The target SharePoint site ID
25
+ # * +drive_id+ - The target SharePoint drive ID within the site
26
+ #
27
+ # === Configuration Sources
28
+ #
29
+ # Configuration can be provided via:
30
+ # * Rails credentials (config/credentials.yml.enc)
31
+ # * Environment variables
32
+ # * Direct parameter passing
33
+ #
34
+ # Example in config/storage.yml:
35
+ #
36
+ # sharepoint:
37
+ # ms_graph_url: <%= Rails.application.credentials.sharepoint[:ms_graph_url] %>
38
+ # ms_graph_version: <%= Rails.application.credentials.sharepoint[:ms_graph_version] %>
39
+ # auth_host: <%= Rails.application.credentials.sharepoint[:auth_host] %>
40
+ # tenant_id: <%= Rails.application.credentials.sharepoint[:oauth_tenant] %>
41
+ # app_id: <%= Rails.application.credentials.sharepoint[:oauth_app_id] %>
42
+ # secret: <%= Rails.application.credentials.sharepoint[:oauth_secret] %>
43
+ # site_id: <%= Rails.application.credentials.sharepoint[:sharepoint_site_id] %>
44
+ # drive_id: <%= Rails.application.credentials.sharepoint[:sharepoint_drive_id] %>
45
+ #
46
+ # === Example Usage
47
+ #
48
+ # config = M365ActiveStorage::Configuration.new(
49
+ # ms_graph_url: "https://graph.microsoft.com",
50
+ # ms_graph_version: "v1.0",
51
+ # auth_host: "https://login.microsoftonline.com",
52
+ # tenant_id: "your-tenant-id",
53
+ # app_id: "your-app-id",
54
+ # secret: "your-client-secret",
55
+ # site_id: "your-site-id",
56
+ # drive_id: "your-drive-id"
57
+ # )
58
+ #
59
+ # puts config.ms_graph_endpoint # => "https://graph.microsoft.com/v1.0"
60
+ #
61
+ # @attr_reader [String] ms_graph_url The Microsoft Graph API base URL
62
+ # @attr_reader [String] ms_graph_version The Graph API version
63
+ # @attr_reader [String] ms_graph_endpoint The complete Graph API endpoint (url + version)
64
+ # @attr_reader [String] auth_host The OAuth2 authentication host
65
+ # @attr_reader [String] tenant_id The Azure AD tenant ID
66
+ # @attr_reader [String] app_id The Azure AD application ID
67
+ # @attr_reader [String] secret The Azure AD client secret
68
+ # @attr_reader [String] site_id The SharePoint site ID
69
+ # @attr_reader [String] drive_id The SharePoint drive ID
70
+ class Configuration
71
+ attr_reader :ms_graph_url, :ms_graph_version, :ms_graph_endpoint,
72
+ :auth_host, :tenant_id,
73
+ :app_id, :secret, :site_id, :drive_id
74
+
75
+ # Initialize Configuration with the provided parameters
76
+ #
77
+ # All parameters are required. Missing parameters will raise a KeyError
78
+ # with a detailed message listing which parameters are missing.
79
+ #
80
+ # @param [Hash] options The configuration parameters
81
+ # @option options [String] :ms_graph_url The Microsoft Graph API base URL
82
+ # @option options [String] :ms_graph_version The Graph API version
83
+ # @option options [String] :auth_host The OAuth2 authentication host
84
+ # @option options [String] :tenant_id The Azure AD tenant ID
85
+ # @option options [String] :app_id The Azure AD application ID
86
+ # @option options [String] :secret The Azure AD client secret
87
+ # @option options [String] :site_id The SharePoint site ID
88
+ # @option options [String] :drive_id The SharePoint drive ID
89
+ #
90
+ # @raise [KeyError] if any required parameter is missing or empty
91
+ #
92
+ # @example
93
+ # config = M365ActiveStorage::Configuration.new(
94
+ # ms_graph_url: "https://graph.microsoft.com",
95
+ # ms_graph_version: "v1.0",
96
+ # # ... other required parameters
97
+ # )
98
+ #
99
+ # @see #validate_configuration!
100
+ def initialize(**options)
101
+ fetch_configuration_params(options)
102
+ validate_configuration!
103
+ rescue KeyError => e
104
+ raise KeyError, "Configuration error: #{e.message}"
105
+ end
106
+
107
+ private
108
+
109
+ # Extract and store configuration parameters from the options hash
110
+ #
111
+ # Accepts keys in any case (string or symbol) and stores them as instance variables.
112
+ #
113
+ # @param [Hash] options The configuration options hash
114
+ # @return [void]
115
+ # @raise [KeyError] if any required parameter is missing
116
+ def fetch_configuration_params(options)
117
+ options = options.with_indifferent_access
118
+ @auth_host = options.fetch(:auth_host)
119
+ @tenant_id = options.fetch(:tenant_id)
120
+ @app_id = options.fetch(:app_id)
121
+ @secret = options.fetch(:secret)
122
+ @ms_graph_url = options.fetch(:ms_graph_url)
123
+ @ms_graph_version = options.fetch(:ms_graph_version)
124
+ @site_id = options.fetch(:site_id)
125
+ @drive_id = options.fetch(:drive_id)
126
+ @ms_graph_endpoint = "#{@ms_graph_url}/#{@ms_graph_version}"
127
+ end
128
+
129
+ # Validate that all required configuration parameters are present and non-empty
130
+ #
131
+ # Checks each configuration parameter and raises a detailed KeyError if any are missing
132
+ # or empty (nil or whitespace-only strings).
133
+ #
134
+ # @return [void]
135
+ # @raise [KeyError] with a detailed message listing all missing parameters
136
+ #
137
+ # @example
138
+ # # If tenant_id, app_id, and secret are missing:
139
+ # # Raises:
140
+ # # KeyError: SharePoint service configuration is incomplete. Missing required parameters::
141
+ # # - @tenant_id
142
+ # # - @app_id
143
+ # # - @secret
144
+ def validate_configuration!
145
+ missing_params = []
146
+ instance_variables.each do |var|
147
+ missing_params << var.to_s.gsub("@", "- ") unless instance_variable_get(var).present?
148
+ end
149
+ return unless missing_params.any?
150
+
151
+ missing_params.unshift("SharePoint service configuration is incomplete. Missing required parameters::")
152
+ raise KeyError, missing_params.join("\n")
153
+ end
154
+ end
155
+ end