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

Introduces the CopyProjectFolderJob #14892

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions app/services/projects/copy_service.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 the OpenProject GmbH
Expand Down Expand Up @@ -41,9 +43,7 @@ def self.copy_dependencies
::Projects::Copy::QueriesDependentService,
::Projects::Copy::BoardsDependentService,
::Projects::Copy::OverviewDependentService,
::Projects::Copy::StoragesDependentService,
::Projects::Copy::StorageProjectFoldersDependentService,
::Projects::Copy::FileLinksDependentService
::Projects::Copy::StoragesDependentService
]
end

Expand Down
2 changes: 2 additions & 0 deletions modules/storages/app/common/storages/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class BaseError < StandardError; end

class ResolverStandardError < BaseError; end

class PollingRequired < BaseError; end

class MissingContract < ResolverStandardError; end

class OperationNotSupported < ResolverStandardError; end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

module Storages
module Peripherals
Nextcloud = Dry::Container::Namespace.new('nextcloud') do
NextcloudRegistry = Dry::Container::Namespace.new('nextcloud') do
namespace('queries') do
register(:auth_check, StorageInteraction::Nextcloud::AuthCheckQuery)
register(:download_link, StorageInteraction::Nextcloud::DownloadLinkQuery)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

module Storages
module Peripherals
OneDrive = Dry::Container::Namespace.new('one_drive') do
OneDriveRegistry = Dry::Container::Namespace.new('one_drive') do
namespace('queries') do
register(:auth_check, StorageInteraction::OneDrive::AuthCheckQuery)
register(:download_link, StorageInteraction::OneDrive::DownloadLinkQuery)
Expand All @@ -44,6 +44,7 @@ module Peripherals
end

namespace('commands') do
register(:copy_template_folder, StorageInteraction::OneDrive::CopyTemplateFolderCommand)
register(:create_folder, StorageInteraction::OneDrive::CreateFolderCommand)
register(:delete_folder, StorageInteraction::OneDrive::DeleteFolderCommand)
register(:rename_file, StorageInteraction::OneDrive::RenameFileCommand)
Expand Down
4 changes: 2 additions & 2 deletions modules/storages/app/common/storages/peripherals/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def call(container, key)
config.resolver = Resolver.new
end

Registry.import Nextcloud
Registry.import OneDrive
Registry.import NextcloudRegistry
Registry.import OneDriveRegistry
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -26,102 +26,109 @@
# See COPYRIGHT and LICENSE files for more details.
#++

module Storages::Peripherals::StorageInteraction::Nextcloud
class CopyTemplateFolderCommand
using Storages::Peripherals::ServiceResultRefinements

def self.call(storage:, source_path:, destination_path:)
new(storage).call(source_path:, destination_path:)
end

def initialize(storage)
@storage = storage
end

def call(source_path:, destination_path:)
valid_input_result = validate_inputs(source_path, destination_path).on_failure { |failure| return failure }

remote_urls = build_origin_urls(**valid_input_result.result)

remote_folder_does_not_exist?(remote_urls[:destination_url]).on_failure { |failure| return failure }

copy_folder(**remote_urls).on_failure { |failure| return failure }

get_folder_id(valid_input_result.result[:destination_path]).on_success do |command_result|
return ServiceResult
.success(result: { id: command_result.result[destination_path]['fileid'], url: remote_urls[:destination_url] })
module Storages
module Peripherals
module StorageInteraction
module Nextcloud
class CopyTemplateFolderCommand
using ServiceResultRefinements

def self.call(storage:, source_path:, destination_path:)
new(storage).call(source_path:, destination_path:)
end

def initialize(storage)
@storage = storage
@data = ResultData::CopyTemplateFolder.new(id: nil, polling_url: nil, requires_polling: false)
end

def call(source_path:, destination_path:)
valid_input_result = validate_inputs(source_path, destination_path).on_failure { |failure| return failure }

remote_urls = build_origin_urls(**valid_input_result.result)

remote_folder_does_not_exist?(remote_urls[:destination_url]).on_failure { |failure| return failure }

copy_folder(**remote_urls).on_failure { |failure| return failure }

get_folder_id(valid_input_result.result[:destination_path]).on_success do |command_result|
return ServiceResult
.success(result: @data.with(id: command_result.result[destination_path]["fileid"]))
end
end

private

def validate_inputs(source_path, destination_path)
if source_path.blank? || destination_path.blank?
return Util.error(:error, "Source and destination paths must be present.")
end

ServiceResult.success(result: { source_path:, destination_path: })
end

def build_origin_urls(source_path:, destination_path:)
escaped_username = CGI.escapeURIComponent(@storage.username)

source_url = Util.join_uri_path(
@storage.uri,
"remote.php/dav/files",
escaped_username,
Util.escape_path(source_path)
)

destination_url = Util.join_uri_path(
@storage.uri,
"remote.php/dav/files",
escaped_username,
Util.escape_path(destination_path)
)

{ source_url:, destination_url: }
end

def remote_folder_does_not_exist?(destination_url)
response = OpenProject.httpx.basic_auth(@storage.username, @storage.password).head(destination_url)

case response
in { status: 200..299 }
Util.error(:conflict, "Destination folder already exists.")
in { status: 401 }
Util.error(:unauthorized, "unauthorized (validate_destination)")
in { status: 404 }
ServiceResult.success
else
Util.error(:unknown, "Unexpected response (validate_destination): #{response.code}", response)
end
end

def copy_folder(source_url:, destination_url:)
response = OpenProject
.httpx
.basic_auth(@storage.username, @storage.password)
.request("COPY", source_url, headers: { "Destination" => destination_url, "Depth" => "infinity" })

case response
in { status: 200..299 }
ServiceResult.success(message: "Folder was successfully copied")
in { status: 401 }
Util.error(:unauthorized, "Unauthorized (copy_folder)")
in { status: 404 }
Util.error(:not_found, "Project folder not found (copy_folder)")
in { status: 409 }
Util.error(:conflict, Util.error_text_from_response(response))
else
Util.error(:unknown, "Unexpected response (copy_folder): #{response.status}", response)
end
end

def get_folder_id(destination_path)
Registry
.resolve("#{@storage.short_provider_type}.queries.file_ids")
.call(storage: @storage, path: destination_path)
end
end
end
end

private

def validate_inputs(source_path, destination_path)
if source_path.blank? || destination_path.blank?
return Util.error(:error, 'Source and destination paths must be present.')
end

ServiceResult.success(result: { source_path:, destination_path: })
end

def build_origin_urls(source_path:, destination_path:)
escaped_username = CGI.escapeURIComponent(@storage.username)

source_url = Util.join_uri_path(
@storage.uri,
"remote.php/dav/files",
escaped_username,
Util.escape_path(source_path)
)

destination_url = Util.join_uri_path(
@storage.uri,
"remote.php/dav/files",
escaped_username,
Util.escape_path(destination_path)
)

{ source_url:, destination_url: }
end

def remote_folder_does_not_exist?(destination_url)
response = OpenProject.httpx.basic_auth(@storage.username, @storage.password).head(destination_url)

case response
in { status: 200..299 }
Util.error(:conflict, 'Destination folder already exists.')
in { status: 401 }
Util.error(:unauthorized, "unauthorized (validate_destination)")
in { status: 404 }
ServiceResult.success
else
Util.error(:unknown, "Unexpected response (validate_destination): #{response.code}", response)
end
end

def copy_folder(source_url:, destination_url:)
response = OpenProject
.httpx
.basic_auth(@storage.username, @storage.password)
.request('COPY', source_url, headers: { 'Destination' => destination_url, 'Depth' => 'infinity' })

case response
in { status: 200..299 }
ServiceResult.success(message: 'Folder was successfully copied')
in { status: 401 }
Util.error(:unauthorized, "Unauthorized (copy_folder)")
in { status: 404 }
Util.error(:not_found, "Project folder not found (copy_folder)")
in { status: 409 }
Util.error(:conflict, Util.error_text_from_response(response))
else
Util.error(:unknown, "Unexpected response (copy_folder): #{response.status}", response)
end
end

def get_folder_id(destination_path)
Storages::Peripherals::Registry
.resolve("#{@storage.short_provider_type}.queries.file_ids")
.call(storage: @storage, path: destination_path)
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def self.call(storage:, source_path:, destination_path:)
return ServiceResult.failure(
result: :error,
errors: StorageError.new(code: :error,
log_message: 'Both source and destination paths need to be present')
log_message: "Both source and destination paths need to be present")
)
end

Expand All @@ -47,6 +47,7 @@ def self.call(storage:, source_path:, destination_path:)

def initialize(storage)
@storage = storage
@data = ResultData::CopyTemplateFolder.new(id: nil, polling_url: nil, requires_polling: true)
end

def call(source_location:, destination_name:)
Expand All @@ -62,29 +63,20 @@ def call(source_location:, destination_name:)
def handle_response(response)
case response
in { status: 202 }
id = extract_id_from_url(response.headers[:location])

ServiceResult.success(result: { id:, url: response.headers[:location] })
ServiceResult.success(result: @data.with(polling_url: response.headers[:location]))
in { status: 401 }
ServiceResult.failure(result: :unauthorized)
in { status: 403 }
ServiceResult.failure(result: :forbidden)
in { status: 404 }
ServiceResult.failure(result: :not_found, message: 'Template folder not found')
ServiceResult.failure(result: :not_found, message: "Template folder not found")
in { status: 409 }
ServiceResult.failure(result: :conflict, message: 'The copy would overwrite an already existing folder')
ServiceResult.failure(result: :conflict, message: "The copy would overwrite an already existing folder")
else
ServiceResult.failure(result: :error)
end
end

def extract_id_from_url(url)
extractor_regex = /.+\/items\/(?<item_id>\w+)\?/
match_data = extractor_regex.match(url)

match_data[:item_id] if match_data
end

def copy_path_for(source_location)
"/v1.0/drives/#{@storage.drive_id}/items/#{source_location}/[email protected]=fail"
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module Storages
module Peripherals
module StorageInteraction
module ResultData
CopyTemplateFolder = Data.define(:id, :polling_url, :requires_polling) do
def requires_polling? = !!requires_polling
end
end
end
end
end
Loading
Loading