Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UPGRADE: Feature/sharepoint online #26

Merged
merged 24 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,15 @@ jobs:
continue-on-error: ${{ matrix.channel != 'stable' }}

env:
SP_USERNAME: test
SP_PASSWORD: test
SP_URL: http://localhost:1234/
SP_AUTHENTICATION: token
SP_CLIENT_ID: clientfoo
SP_CLIENT_SECRET: secretfoo
SP_TENANT_ID: tenantfoo
SP_CERT_NAME: certfoo
SP_AUTH_SCOPE: http://localhost:1234/
SP_USERNAME: userfoo
SP_PASSWORD: passfoo

steps:
- name: Install libmagic-dev
Expand Down
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,28 @@ And then execute:

You can instantiate a number of SharePoint clients in your application:

#### Token authentication

```rb
client = Sharepoint::Client.new({
username: 'username',
password: 'password',
uri: 'https://sharepoint_url'
authentication: "token",
client_id: "client_id",
client_secret: "client_secret",
tenant_id: "tenant_id",
cert_name: "cert_name",
auth_scope: "auth_scope",
uri: "http://sharepoint_url"
})
```

#### NTLM authentication

```rb
client = Sharepoint::Client.new({
authentication: "ntlm",
username: "username",
password: "password",
uri: "http://sharepoint_url"
})
```

Expand All @@ -47,3 +64,11 @@ client.upload filename, content, path
```rb
client.update_metadata filename, metadata, path
```

## Testing

Create a .env file based on the `env-example` and run

```bash
$ bundle exec rake
```
13 changes: 10 additions & 3 deletions env-example
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
# SharePoint Access
SP_USERNAME=
SP_PASSWORD=
SP_URL=
SP_AUTHENTICATION=token
SP_CLIENT_ID=foo
SP_CLIENT_SECRET=foo
SP_TENANT_ID=foo
SP_CERT_NAME=foo
SP_AUTH_SCOPE=https://foobar.example.org
SP_URL=https://foobar.example.org
SP_USERNAME=foo
SP_PASSWORD=foo
SP_AUTHENTICATION= token | ntlm
153 changes: 118 additions & 35 deletions lib/sharepoint/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,45 @@

require 'active_support/core_ext/string/inflections'
require 'active_support/core_ext/object/blank'
require 'sharepoint/client/token'

module Sharepoint
class Client
FILENAME_INVALID_CHARS = '~"#%&*:<>?/\{|}'

def authenticating_with_token
generate_new_token
yield
end

def generate_new_token
token.retrieve
end

def bearer_auth
"Bearer #{token}"
end

# @return [OpenStruct] The current configuration.
attr_reader :config
attr_reader :token

# Initializes a new client with given options.
#
# @param [Hash] config The client options:
# - `:uri` The SharePoint server's root url
# - `:authentication` The authentication method to use [:ntlm, :token]
# - `:username` self-explanatory
# - `:password` self-explanatory
# - `:client_id` self-explanatory
# - `:client_secret` self-explanatory
# - `:tenant_id` self-explanatory
# - `:cert_name` self-explanatory
# - `:auth_scope` self-explanatory
gridanjbf marked this conversation as resolved.
Show resolved Hide resolved
# @return [Sharepoint::Client] client object
def initialize(config = {})
@config = OpenStruct.new(config)
@token = Token.new(@config)
validate_config!
end

Expand Down Expand Up @@ -304,11 +326,11 @@ def create_folder(name, path, site_path = nil)
path = path[1..-1] if path[0].eql?('/')
url = uri_escape "#{url}GetFolderByServerRelativeUrl('#{path}')/Folders"
easy = ethon_easy_json_requester
easy.headers = {
'accept' => 'application/json;odata=verbose',
'content-type' => 'application/json;odata=verbose',
'X-RequestDigest' => xrequest_digest(site_path)
}
easy.headers = with_bearer_authentication_header({
'accept' => 'application/json;odata=verbose',
'content-type' => 'application/json;odata=verbose',
'X-RequestDigest' => xrequest_digest(site_path)
})
payload = {
'__metadata' => {
'type' => 'SP.Folder'
Expand Down Expand Up @@ -351,10 +373,8 @@ def upload(filename, content, path, site_path = nil)
path = path[1..-1] if path[0].eql?('/')
url = uri_escape "#{url}GetFolderByServerRelativeUrl('#{path}')/Files/Add(url='#{sanitized_filename}',overwrite=true)"
easy = ethon_easy_json_requester
easy.headers = {
'accept' => 'application/json;odata=verbose',
'X-RequestDigest' => xrequest_digest(site_path)
}
easy.headers = with_bearer_authentication_header({ 'accept' => 'application/json;odata=verbose',
'X-RequestDigest' => xrequest_digest(site_path) })
tagliala marked this conversation as resolved.
Show resolved Hide resolved
easy.http_request(url, :post, { body: content })
easy.perform
check_and_raise_failure(easy)
Expand Down Expand Up @@ -382,13 +402,11 @@ def update_metadata(filename, metadata, path, site_path = nil)
prepared_metadata = prepare_metadata(metadata, __metadata['type'])

easy = ethon_easy_json_requester
easy.headers = {
'accept' => 'application/json;odata=verbose',
'content-type' => 'application/json;odata=verbose',
'X-RequestDigest' => xrequest_digest(site_path),
'X-Http-Method' => 'PATCH',
'If-Match' => '*'
}
easy.headers = with_bearer_authentication_header({ 'accept' => 'application/json;odata=verbose',
'content-type' => 'application/json;odata=verbose',
'X-RequestDigest' => xrequest_digest(site_path),
'X-Http-Method' => 'PATCH',
'If-Match' => '*' })
tagliala marked this conversation as resolved.
Show resolved Hide resolved
easy.http_request(update_metadata_url,
:post,
{ body: prepared_metadata })
Expand Down Expand Up @@ -456,6 +474,10 @@ def index_field(list_name, field_name, site_path = '')
update_object_metadata parsed_response_body['d']['__metadata'], { 'Indexed' => true }, site_path
end

def requester
ethon_easy_requester
end

private

def process_url(url, fields)
Expand All @@ -478,6 +500,24 @@ def process_url(url, fields)
end
end

def token_auth?
config.authentication == 'token'
end

def ntlm_auth?
config.authentication == 'ntlm'
end
gridanjbf marked this conversation as resolved.
Show resolved Hide resolved

def with_bearer_authentication_header(h)
return h if ntlm_auth?

h.merge(bearer_auth_header)
end

def bearer_auth_header
{ 'Authorization' => bearer_auth }
end

def base_url
config.uri
end
Expand All @@ -504,7 +544,7 @@ def computed_web_api_url(site)

def ethon_easy_json_requester
easy = ethon_easy_requester
easy.headers = { 'accept' => 'application/json;odata=verbose' }
easy.headers = with_bearer_authentication_header({ 'accept' => 'application/json;odata=verbose' })
easy
end

Expand All @@ -513,10 +553,18 @@ def ethon_easy_options
end

def ethon_easy_requester
easy = Ethon::Easy.new({ httpauth: :ntlm, followlocation: 1, maxredirs: 5 }.merge(ethon_easy_options))
easy.username = config.username
easy.password = config.password
easy
if token_auth?
authenticating_with_token do
easy = Ethon::Easy.new({ followlocation: 1, maxredirs: 5 }.merge(ethon_easy_options))
easy.headers = with_bearer_authentication_header({})
easy
end
elsif ntlm_auth?
easy = Ethon::Easy.new({ httpauth: :ntlm, followlocation: 1, maxredirs: 5 }.merge(ethon_easy_options))
easy.username = config.username
easy.password = config.password
easy
end
end

# When you send a POST request, the request must include the form digest
Expand Down Expand Up @@ -584,26 +632,63 @@ def extract_paths(url)
}
end

def validate_token_config
valid_config_options(%i[client_id client_secret tenant_id cert_name auth_scope])
end

def validate_ntlm_config
valid_config_options(%i[username password])
end

def valid_config_options(options = [])
options.map do |opt|
c = config.send(opt)

next if c.present? && string_not_blank?(c)

opt
end.compact
end

def validate_config!
raise Errors::UsernameConfigurationError.new unless string_not_blank?(@config.username)
raise Errors::PasswordConfigurationError.new unless string_not_blank?(@config.password)
raise Errors::UriConfigurationError.new unless valid_config_uri?
raise Errors::EthonOptionsConfigurationError.new unless ethon_easy_options.is_a?(Hash)
raise Errors::InvalidAuthenticationError.new unless valid_authentication?(config.authentication)

validate_token_config! if config.authentication == 'token'
validate_ntlm_config! if config.authentication == 'ntlm'

raise Errors::UriConfigurationError.new unless valid_uri?(config.uri)
raise Errors::EthonOptionsConfigurationError.new unless ethon_easy_options.is_a?(Hash)
end

def validate_token_config!
invalid_token_opts = validate_token_config

raise Errors::InvalidTokenConfigError.new(invalid_token_opts) unless invalid_token_opts.empty?
end

def validate_ntlm_config!
invalid_ntlm_opts = validate_ntlm_config

raise Errors::InvalidNTLMConfigError.new(invalid_ntlm_opts) unless invalid_ntlm_opts.empty?
end

def string_not_blank?(object)
!object.nil? && object != '' && object.is_a?(String)
end

def valid_config_uri?
if @config.uri and @config.uri.is_a? String
uri = URI.parse(@config.uri)
def valid_uri?(which)
if which and which.is_a? String
uri = URI.parse(which)
uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
else
false
end
end

def valid_authentication?(which)
%w[ntlm token].include?(which)
end

# Waiting for RFC 3986 to be implemented, we need to escape square brackets
def uri_escape(uri)
URI::DEFAULT_PARSER.escape(uri).gsub('[', '%5B').gsub(']', '%5D')
Expand Down Expand Up @@ -756,13 +841,11 @@ def update_object_metadata(metadata, new_metadata, site_path = '')
prepared_metadata = prepare_metadata(new_metadata, metadata['type'])

easy = ethon_easy_json_requester
easy.headers = {
'accept' => 'application/json;odata=verbose',
'content-type' => 'application/json;odata=verbose',
'X-RequestDigest' => xrequest_digest(site_path),
'X-Http-Method' => 'PATCH',
'If-Match' => '*'
}
easy.headers = with_bearer_authentication_header({ 'accept' => 'application/json;odata=verbose',
'content-type' => 'application/json;odata=verbose',
'X-RequestDigest' => xrequest_digest(site_path),
'X-Http-Method' => 'PATCH',
'If-Match' => '*' })
tagliala marked this conversation as resolved.
Show resolved Hide resolved

easy.http_request(update_metadata_url,
:post,
Expand Down
64 changes: 64 additions & 0 deletions lib/sharepoint/client/token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# frozen_string_literal: true

module Sharepoint
class Client
class Token # rubocop:disable Style/Documentation
class InvalidTokenError < StandardError
end
tagliala marked this conversation as resolved.
Show resolved Hide resolved

attr_accessor :expires_in, :access_token, :fetched_at
attr_reader :config

def initialize(config)
@config = config
end

def retrieve
return access_token unless access_token.nil? || expired?

fetch
end

def to_s
access_token
end

def fetch
response = request_new_token

details = response['Token']
self.fetched_at = Time.now.utc.to_i
self.expires_in = details['expires_in']
self.access_token = details['access_token']
end

private

def expired?
return true unless fetched_at && expires_in

(fetched_at + expires_in) < Time.now.utc.to_i
end

def request_new_token
auth_request = {
client_id: config.client_id,
client_secret: config.client_secret,
tenant_id: config.tenant_id,
cert_name: config.cert_name,
auth_scope: config.auth_scope
}.to_json

headers = { 'Content-Type' => 'application/json' }

ethon = Ethon::Easy.new(followlocation: true)
ethon.http_request(config.token_url, :post, body: auth_request, headers: headers)
ethon.perform

raise InvalidTokenError.new(ethon.response_body.to_s) unless ethon.response_code == 200

JSON.parse(ethon.response_body)
end
end
end
end
Loading