Skip to content

Commit

Permalink
Merge pull request opf#14648 from opf/impl/sharepoint-sync-service
Browse files Browse the repository at this point in the history
Implements the OneDrive Sync Service
  • Loading branch information
mereghost authored Feb 2, 2024
2 parents ea1c617 + 6e9fb80 commit 16c1253
Show file tree
Hide file tree
Showing 73 changed files with 16,536 additions and 1,418 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
Expand Down Expand Up @@ -54,19 +54,19 @@ def call(folder_path:)
private

def handle_response(response)
data = ::Storages::StorageErrorData.new(source: self, payload: response.body)
data = ::Storages::StorageErrorData.new(source: self.class, payload: response)

case response.status
when 200..299
case response
in { status: 200..299 }
ServiceResult.success(result: file_info_for(MultiJson.load(response.body, symbolize_keys: true)),
message: 'Folder was successfully created.')
when 404
in { status: 404 }
ServiceResult.failure(result: :not_found,
errors: ::Storages::StorageError.new(code: :not_found, data:))
when 401
in { status: 401 }
ServiceResult.failure(result: :unauthorized,
errors: ::Storages::StorageError.new(code: :unauthorized, data:))
when 409
in { status: 409 }
ServiceResult.failure(result: :already_exists,
errors: ::Storages::StorageError.new(code: :conflict, data:))
else
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
Expand Down Expand Up @@ -48,17 +48,17 @@ def call(location:)

data = ::Storages::StorageErrorData.new(source: self.class, payload: response)

case response.status
when 200..299
case response
in { status: 200..299 }
# The service returns a 204 with an empty body
ServiceResult.success
when 401
in { status: 401 }
ServiceResult.failure(result: :unauthorized,
errors: ::Storages::StorageError.new(code: :unauthorized, data:))
when 404
in { status: 404 }
ServiceResult.failure(result: :not_found,
errors: ::Storages::StorageError.new(code: :not_found, data:))
when 409
in { status: 409 }
ServiceResult.failure(result: :conflict,
errors: ::Storages::StorageError.new(code: :conflict, data:))
else
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def call(user:, file_id:)
return ServiceResult.failure(
result: :error,
errors: ::Storages::StorageError.new(code: :error,
data: StorageErrorData.new(source: self),
data: StorageErrorData.new(source: self.class),
log_message: 'File ID can not be nil')
)
end
Expand All @@ -66,8 +66,8 @@ def storage_file_infos
status_code: 200,
id: json[:id],
name: json[:name],
last_modified_at: DateTime.parse(json.dig(:fileSystemInfo, :lastModifiedDateTime)),
created_at: DateTime.parse(json.dig(:fileSystemInfo, :createdDateTime)),
last_modified_at: Time.zone.parse(json.dig(:fileSystemInfo, :lastModifiedDateTime)),
created_at: Time.zone.parse(json.dig(:fileSystemInfo, :createdDateTime)),
mime_type: Util.mime_type(json),
size: json[:size],
owner_name: json.dig(:createdBy, :user, :displayName),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def handle_response(response, map_value)
ServiceResult.failure(result: :unauthorized,
errors: Util.storage_error(response:, code: :unauthorized, source: self))
else
data = ::Storages::StorageErrorData.new(source: self, payload: response)
data = ::Storages::StorageErrorData.new(source: self.class, payload: response)
ServiceResult.failure(result: :error, errors: ::Storages::StorageError.new(code: :error, data:))
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def handle_responses(response)
ServiceResult.failure(result: :unauthorized,
errors: UTIL.storage_error(response:, code: :unauthorized, source: self))
else
data = ::Storages::StorageErrorData.new(source: self, payload: response)
data = ::Storages::StorageErrorData.new(source: self.class, payload: response)
ServiceResult.failure(result: :error, errors: ::Storages::StorageError.new(code: :error, data:))
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def handle_responses(response)
ServiceResult.failure(result: :unauthorized,
errors: Util.storage_error(response:, code: :unauthorized, source: self))
else
data = ::Storages::StorageErrorData.new(source: self, payload: response)
data = ::Storages::StorageErrorData.new(source: self.class, payload: response)
ServiceResult.failure(result: :error, errors: ::Storages::StorageError.new(code: :error, data:))
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
# Copyright (C) 2012-2024 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
Expand Down Expand Up @@ -55,16 +55,16 @@ def call(source:, target:)
def handle_response(response)
data = ::Storages::StorageErrorData.new(source: self.class, payload: response)

case response.status
when 200..299
case response
in { status: 200..299 }
ServiceResult.success(result: storage_file(response.json(symbolize_keys: true)))
when 401
in { status: 401 }
ServiceResult.failure(result: :unauthorized,
errors: ::Storages::StorageError.new(code: :unauthorized, data:))
when 404
in { status: 404 }
ServiceResult.failure(result: :not_found,
errors: ::Storages::StorageError.new(code: :not_found, data:))
when 409
in { status: 409 }
ServiceResult.failure(result: :conflict,
errors: ::Storages::StorageError.new(code: :conflict, data:))
else
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,12 @@ module OneDrive
class SetPermissionsCommand
using ServiceResultRefinements

PermissionUpdateData = Data.define(:role, :permission_id, :user_ids, :drive_item_id) do
def create?
user_ids.any? && permission_id.nil?
end
PermissionUpdateData = Data.define(:role, :permission_ids, :user_ids, :drive_item_id) do
def create? = permission_ids.empty? && user_ids.any?

def delete?
permission_id && user_ids.empty?
end
def delete? = permission_ids.any? && user_ids.empty?

def update?
permission_id && user_ids.any?
end
def update? = permission_ids.any? && user_ids.any?
end

def self.call(storage:, path:, permissions:)
Expand All @@ -67,10 +61,12 @@ def call(path:, permissions:)
permission_ids = extract_permission_ids(current_permissions[:value])

permissions.each_pair do |role, user_ids|
update_data = PermissionUpdateData.new(role:, user_ids:, permission_id: permission_ids[role], drive_item_id: path)

apply_permission_changes(update_data)
apply_permission_changes(
PermissionUpdateData.new(role:, user_ids:, permission_ids: permission_ids[role], drive_item_id: path)
)
end

ServiceResult.success
end

private
Expand All @@ -87,11 +83,12 @@ def apply_permission_changes(update_data)
return delete_permissions(update_data) if update_data.delete?
return create_permissions(update_data) if update_data.create?

update_permissions(update_data)
update_permissions(update_data) if update_data.update?
end

def update_permissions(update_data)
delete_permissions(update_data).on_success { create_permissions(update_data) }
delete_permissions(update_data)
create_permissions(update_data)
end

def create_permissions(update_data)
Expand All @@ -106,23 +103,31 @@ def create_permissions(update_data)
recipients: drive_recipients
}.to_json)

handle_response(response)
handle_response(response).result_or { |error| log_error(error) }
end
end

def delete_permissions(update_data)
Util.using_admin_token(@storage) do |http|
handle_response(http.delete(permission_path(update_data.drive_item_id, update_data.permission_id)))
update_data.permission_ids.each do |permission_id|
handle_response(
http.delete(permission_path(update_data.drive_item_id, permission_id))
).result_or { |error| log_error(error) }
end
end
end

# This will grab the first write or read permission.
# If the folder is setup correctly this should be enough
def extract_permission_ids(permission_set)
write_permission = permission_set.find(-> { {} }) { |hash| hash[:roles].first == 'write' }[:id]
read_permission = permission_set.find(-> { {} }) { |hash| hash[:roles].first == 'read' }[:id]
filter = ->(role, permission) do
next unless permission[:roles].member?(role)

{ read: read_permission, write: write_permission }
permission[:id]
end.curry

write_permissions = permission_set.filter_map(&filter.call('write'))
read_permissions = permission_set.filter_map(&filter.call('read'))

{ read: read_permissions, write: write_permissions }
end

def handle_response(response)
Expand All @@ -132,7 +137,9 @@ def handle_response(response)
in { status: 200 }
ServiceResult.success(result: response.json(symbolize_keys: true))
in { status: 204 }
ServiceResult.success
ServiceResult.success(result: response)
in { status: 400 }
ServiceResult.failure(result: :bad_request, errors: ::Storages::StorageError.new(code: :bad_request, data:))
in { status: 401 }
ServiceResult.failure(result: :unauthorized, errors: ::Storages::StorageError.new(code: :unauthorized, data:))
in { status: 403 }
Expand All @@ -159,6 +166,13 @@ def invite_path(item_id)
def item_path(item_id)
"/v1.0/drives/#{@storage.drive_id}/items/#{item_id}"
end

def log_error(error)
OpenProject.logger.warn({ command: error.data.source,
message: error.log_message,
data: { status: error.data.payload.status,
body: error.data.payload.body.to_s } })
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def mime_type(json)

def using_user_token(storage, user, &)
connection_manager = ::OAuthClients::ConnectionManager
.new(user:, configuration: storage.oauth_configuration)
.new(user:, configuration: storage.oauth_configuration)

connection_manager
.get_access_token
Expand Down Expand Up @@ -75,15 +75,27 @@ def join_uri_path(uri, *)

def using_admin_token(storage)
oauth_client = storage.oauth_configuration.basic_rack_oauth_client
token = Rails.cache.fetch("storage.#{storage.id}.access_token", expires_in: 50.minutes) do
oauth_client.access_token!(scope: 'https://graph.microsoft.com/.default')

token_result = begin
Rails.cache.fetch("storage.#{storage.id}.access_token", expires_in: 50.minutes) do
ServiceResult.success(result: oauth_client.access_token!(scope: 'https://graph.microsoft.com/.default'))
end
rescue Rack::OAuth2::Client::Error => e
ServiceResult.failure(errors: ::Storages::StorageError.new(
code: :unauthorized,
data: ::Storages::StorageErrorData.new(source: self.class),
log_message: e.message
))
end

yield OpenProject.httpx.with(
origin: storage.uri,
headers: {
authorization: "Bearer #{token.access_token}", accept: "application/json", 'content-type': 'application/json'
}
token_result.match(
on_success: ->(token) do
yield OpenProject.httpx.with(origin: storage.uri,
headers: { authorization: "Bearer #{token.access_token}",
accept: "application/json",
'content-type': 'application/json' })
end,
on_failure: ->(errors) { ServiceResult.failure(result: :unauthorized, errors:) }
)
end

Expand Down
22 changes: 0 additions & 22 deletions modules/storages/app/models/storages/nextcloud_storage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,32 +41,10 @@ class NextcloudStorage < Storage
store_attribute :provider_fields, :group, :string
store_attribute :provider_fields, :group_folder, :string

scope :automatic_management_enabled, -> { where("provider_fields->>'automatically_managed' = 'true'") }

def oauth_configuration
Peripherals::OAuthConfigurations::NextcloudConfiguration.new(self)
end

def automatically_managed?
ActiveSupport::Deprecation.warn(
'`#automatically_managed?` is deprecated. Use `#automatic_management_enabled?` instead. ' \
'NOTE: The new method name better reflects the actual behavior of the storage. ' \
"It's not the storage that is automatically managed, rather the Project (Storage) Folder is. " \
"A storage only has this feature enabled or disabled."
)
super
end

def automatic_management_enabled=(value)
self.automatically_managed = value
end

alias automatic_management_enabled automatically_managed

def automatic_management_enabled?
!!automatically_managed
end

def automatic_management_new_record?
if provider_fields_changed?
previous_configuration = provider_fields_change.first
Expand Down
28 changes: 28 additions & 0 deletions modules/storages/app/models/storages/storage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ class Storage < ApplicationRecord

self.inheritance_column = :provider_type

store_attribute :provider_fields, :automatically_managed, :boolean

has_many :file_links, class_name: 'Storages::FileLink'
belongs_to :creator, class_name: 'User'
has_many :project_storages, dependent: :destroy, class_name: 'Storages::ProjectStorage'
Expand All @@ -78,6 +80,8 @@ class Storage < ApplicationRecord
where.not(id: project.project_storages.pluck(:storage_id))
end

scope :automatic_management_enabled, -> { where("provider_fields->>'automatically_managed' = 'true'") }

enum health_status: {
pending: 'pending',
healthy: 'healthy',
Expand Down Expand Up @@ -131,6 +135,30 @@ def mark_as_healthy
end
end

def automatically_managed?
ActiveSupport::Deprecation.warn(
'`#automatically_managed?` is deprecated. Use `#automatic_management_enabled?` instead. ' \
'NOTE: The new method name better reflects the actual behavior of the storage. ' \
"It's not the storage that is automatically managed, rather the Project (Storage) Folder is. " \
"A storage only has this feature enabled or disabled."
)
super
end

def automatic_management_enabled?
!!automatically_managed
end

def automatic_management_unspecified?
automatically_managed.nil?
end

def automatic_management_enabled=(value)
self.automatically_managed = value
end

alias automatic_management_enabled automatically_managed

def configured?
configuration_checks.values.all?
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
#++

module Storages
class GroupFolderPropertiesSyncService
class NextcloudGroupFolderPropertiesSyncService
using Peripherals::ServiceResultRefinements

PERMISSIONS_MAP = {
Expand Down
Loading

0 comments on commit 16c1253

Please sign in to comment.