Skip to content

Commit 01bc9f1

Browse files
WIP: Rework "open storage" action
The previous implementation was a deeply nested decision tree for different error cases or other unexpected states. Most of this was necessary, because setting up correct permissions is partially happening asynchronously. Some of the redirects would eventually have rendered a modal that would periodically check whether user permissions appeared in the background. All of this was replaced by an approach where we immediately (synchronously) ensure that the setup is correct.
1 parent 0727898 commit 01bc9f1

14 files changed

+513
-365
lines changed

modules/storages/app/common/storages/peripherals/nextcloud_registry.rb

+5
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ module Peripherals
8585
register(:managed_folder_identifier, ManagedFolderIdentifier::Nextcloud)
8686
end
8787

88+
namespace("services") do
89+
register(:folder_create, ::Storages::NextcloudManagedFolderCreateService)
90+
register(:folder_permissions, ::Storages::NextcloudManagedFolderPermissionsService)
91+
end
92+
8893
namespace("validators") do
8994
register(:connection, ConnectionValidators::NextcloudValidator)
9095
end

modules/storages/app/common/storages/peripherals/one_drive_registry.rb

+5
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ module Peripherals
7676
register(:managed_folder_identifier, ManagedFolderIdentifier::OneDrive)
7777
end
7878

79+
namespace("services") do
80+
register(:folder_create, ::Storages::OneDriveManagedFolderCreateService)
81+
register(:folder_permissions, ::Storages::OneDriveManagedFolderPermissionsService)
82+
end
83+
7984
namespace("validations") do
8085
register(:connection, ConnectionValidators::OneDriveValidator)
8186
end

modules/storages/app/controllers/storages/project_storages_controller.rb

+74-87
Original file line numberDiff line numberDiff line change
@@ -40,110 +40,97 @@ class Storages::ProjectStoragesController < ApplicationController
4040
before_action :render_403, unless: -> { User.current.allowed_in_project?(:view_file_links, @project) }
4141
no_authorization_required! :open
4242

43-
# rubocop:disable Metrics/AbcSize
43+
before_action :ensure_remote_identity, only: :open
44+
before_action :ensure_folder_created, only: :open
45+
before_action :ensure_folder_permissions, only: :open
46+
4447
def open
45-
if @object.project_folder_automatic?
46-
@storage = @object.storage
47-
# check if user "see" project_folder
48-
if @object.project_folder_id.present?
49-
::Storages::Peripherals::Registry
50-
.resolve("#{@storage}.queries.file_info")
51-
.call(storage: @storage, auth_strategy:, file_id: @object.project_folder_id)
52-
.match(
53-
on_success: user_can_read_project_folder,
54-
on_failure: user_can_not_read_project_folder
55-
)
56-
else
57-
respond_to do |format|
58-
format.turbo_stream { head :no_content }
59-
format.html do
60-
redirect_to_project_overview_with_modal
61-
end
62-
end
63-
end
64-
else
65-
redirect_to api_v3_project_storage_open
66-
end
48+
@project_storage.open(current_user).match(
49+
on_success: ->(url) { redirect_to url, allow_other_host: true },
50+
on_failure: ->(error) { raise error }
51+
)
6752
end
6853

69-
# rubocop:enable Metrics/AbcSize
70-
7154
private
7255

73-
def auth_strategy
74-
Storages::Peripherals::Registry.resolve("#{@storage}.authentication.user_bound").call(user: current_user, storage: @storage)
75-
end
56+
def ensure_remote_identity
57+
selector = Storages::Peripherals::StorageInteraction::AuthenticationMethodSelector.new(user: current_user, storage:)
58+
return unless selector.storage_oauth?
7659

77-
def user_can_read_project_folder
78-
->(_) do
79-
respond_to do |format|
80-
format.turbo_stream do
81-
render(
82-
turbo_stream: OpTurbo::StreamComponent.new(
83-
action: :update,
84-
target: Storages::OpenProjectStorageModalComponent.dialog_body_id,
85-
template: Storages::OpenProjectStorageModalComponent::Body.new(:success).render_in(view_context)
86-
).render_in(view_context)
87-
)
88-
end
89-
format.html { redirect_to api_v3_project_storage_open }
90-
end
60+
case Storages::Peripherals::StorageInteraction::Authentication.authorization_state(storage:, user: current_user)
61+
when :not_connected, :failed_authorization
62+
redirect_to ensure_connection_url
63+
when :error
64+
show_error(I18n.t("project_storages.open.remote_identity_error"))
9165
end
9266
end
9367

94-
def user_can_not_read_project_folder
95-
->(result) do
96-
respond_to do |format|
97-
format.turbo_stream { head :no_content }
98-
format.html do
99-
case result.code
100-
when :unauthorized
101-
redirect_to(storage_fallback_url, allow_other_host: true)
102-
when :forbidden
103-
redirect_to_project_overview_with_modal
104-
end
105-
end
106-
end
68+
def ensure_folder_created
69+
return unless @project_storage.project_folder_automatic?
70+
return if @project_storage.project_folder_id.present?
71+
72+
folder_create_service.new(storage:, project_storages: project_storage_scope).call.on_failure do |result|
73+
return show_error(result.errors.full_messages)
10774
end
75+
@project_storage.reload
10876
end
10977

110-
def storage_fallback_url
111-
selector = Storages::Peripherals::StorageInteraction::AuthenticationMethodSelector.new(user: current_user, storage: @storage)
112-
if selector.sso?
113-
# Maybe the user just can't read folder because they are not provisioned in (Nextcloud) storage. We redirect them
114-
# to the storage and leave error handling up to storage. Ideally they will login to the storage and thus prevent
115-
# the same error in the future.
116-
# This would not work for OneDrive, but for OneDrive we don't have SSO (yet).
117-
res = Storages::Peripherals::Registry.resolve("#{@storage}.queries.open_file_link").call(
118-
storage: @storage,
119-
auth_strategy:,
120-
file_id: @object.project_folder_id
121-
)
122-
res.result_or { |errors| raise "Could not redirect SSO user to storage: #{errors}" }
123-
else
124-
oauth_clients_ensure_connection_url(
125-
oauth_client_id: @storage.oauth_client.client_id,
126-
storage_id: @storage.id,
127-
destination_url: request.url
128-
)
78+
def ensure_folder_permissions
79+
return unless @project_storage.project_folder_automatic?
80+
81+
result = test_folder_access
82+
return if result.success? || result.errors.code != :forbidden
83+
84+
# Note: The time this operation takes may still scale with the number of users.
85+
# If this becomes a problem, we will have to update downstream code to allow changing permissions for a few users instead of
86+
# requiring to always set all permissions.
87+
folder_permissions_service.new(storage:, project_storages: project_storage_scope).call.on_failure do |r|
88+
return show_error(r.errors.full_messages)
12989
end
13090
end
13191

132-
def redirect_to_project_overview_with_modal
133-
redirect_to(
134-
project_overview_path(project_id: @project.identifier),
135-
op_modal: {
136-
component: Storages::OpenProjectStorageModalComponent.name,
137-
parameters: {
138-
project_storage_open_url: request.path,
139-
redirect_url: api_v3_project_storage_open,
140-
state: :waiting
141-
}
142-
}
92+
def ensure_connection_url
93+
oauth_clients_ensure_connection_url(
94+
oauth_client_id: storage.oauth_client.client_id,
95+
storage_id: storage.id,
96+
destination_url: request.url
97+
)
98+
end
99+
100+
def folder_create_service
101+
Storages::Peripherals::Registry.resolve("#{storage}.services.folder_create")
102+
end
103+
104+
def folder_permissions_service
105+
Storages::Peripherals::Registry.resolve("#{storage}.services.folder_permissions")
106+
end
107+
108+
def file_info
109+
Storages::Peripherals::Registry.resolve("#{storage}.queries.file_info")
110+
end
111+
112+
def user_bound
113+
Storages::Peripherals::Registry.resolve("#{storage}.authentication.user_bound")
114+
end
115+
116+
def storage
117+
@object.storage
118+
end
119+
120+
def project_storage_scope
121+
Storages::ProjectStorage.where(id: @project_storage.id)
122+
end
123+
124+
def test_folder_access
125+
file_info.call(
126+
storage:,
127+
auth_strategy: user_bound.call(storage:, user: current_user),
128+
file_id: @project_storage.project_folder_id
143129
)
144130
end
145131

146-
def api_v3_project_storage_open
147-
::API::V3::Utilities::PathHelper::ApiV3Path.project_storage_open(@object.id)
132+
def show_error(message)
133+
flash[:error] = message
134+
redirect_back(fallback_location: project_path(id: @project_storage.project_id))
148135
end
149136
end

modules/storages/app/models/storages/project_storage.rb

+4-9
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def open(user)
9797

9898
def open_with_connection_ensured
9999
return unless storage.configured?
100-
return open_project_storage_url if storage.authenticate_via_idp?
100+
return open_project_storage_url if storage.authenticate_via_idp? # TODO: use auth method selector
101101

102102
OpenProject::StaticRouting::StaticRouter.new.url_helpers.oauth_clients_ensure_connection_path(
103103
oauth_client_id: storage.oauth_client.client_id,
@@ -106,17 +106,12 @@ def open_with_connection_ensured
106106
)
107107
end
108108

109-
private
110-
111109
def open_project_storage_url
112-
OpenProject::StaticRouting::StaticRouter
113-
.new
114-
.url_helpers.open_project_storage_url(host: Setting.host_name,
115-
protocol: "https",
116-
project_id: project.identifier,
117-
id:)
110+
OpenProject::StaticRouting::StaticRouter.new.url_helpers.open_project_storage_url(project_id: project.identifier, id:)
118111
end
119112

113+
private
114+
120115
def managed_folder_identifier
121116
@managed_folder_identifier ||=
122117
Peripherals::Registry.resolve("#{storage}.models.managed_folder_identifier").new(self)

modules/storages/app/common/storages/injector.rb renamed to modules/storages/app/services/storages/managed_folder_sync_service.rb

+44-3
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,49 @@
2828
# See COPYRIGHT and LICENSE files for more details.
2929
#++
3030

31-
require "dry/auto_inject"
32-
3331
module Storages
34-
Injector = Dry::AutoInject(Peripherals::Registry)
32+
class ManagedFolderSyncService < BaseService
33+
using Peripherals::ServiceResultRefinements
34+
35+
def initialize(storage)
36+
super()
37+
@storage = storage
38+
end
39+
40+
def call
41+
with_tagged_logger([self.class.name, "storage-#{@storage.id}"]) do
42+
info "Starting AMPF Sync for Storage #{@storage.id}"
43+
prepare_remote_folders.on_failure { return epilogue }
44+
apply_permissions_to_folders
45+
epilogue
46+
end
47+
end
48+
49+
private
50+
51+
def epilogue
52+
info "Synchronization process for Storage #{@storage.id} has ended. #{@result.errors.count} errors found."
53+
@result
54+
end
55+
56+
def prepare_remote_folders
57+
folder_create_service.new(storage: @storage).call.tap do |subresult|
58+
@result.merge!(subresult)
59+
end
60+
end
61+
62+
def apply_permissions_to_folders
63+
folder_permissions_service.new(storage: @storage).call.tap do |subresult|
64+
@result.merge!(subresult)
65+
end
66+
end
67+
68+
def folder_create_service
69+
Peripherals::Registry.resolve("#{@storage}.services.folder_create")
70+
end
71+
72+
def folder_permissions_service
73+
Peripherals::Registry.resolve("#{@storage}.services.folder_permissions")
74+
end
75+
end
3576
end

0 commit comments

Comments
 (0)