From b8ab84fef4a82e3b283d984b11ab41282acead49 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 11 Nov 2026 14:47:18 +0100 Subject: [PATCH 01/23] fix(Sharing): Fix event listeners with ghosts Signed-off-by: provokateurin --- apps/files/lib/AppInfo/Application.php | 5 +++++ .../lib/Sharing/Source/NodeShareSourceType.php | 2 -- apps/sharing/lib/AppInfo/Application.php | 4 ++++ build/psalm-baseline.xml | 14 ++++++++------ core/AppInfo/Application.php | 6 ++++++ core/Sharing/Recipient/GroupShareRecipientType.php | 1 - core/Sharing/Recipient/TeamShareRecipientType.php | 1 - core/Sharing/Recipient/UserShareRecipientType.php | 1 - lib/private/Sharing/SharingManager.php | 2 -- 9 files changed, 23 insertions(+), 13 deletions(-) diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php index 516c071c35342..5b6e8221fa6a2 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -46,6 +46,7 @@ use OCA\Files\Sharing\Permission\NodeUpdateSharePermissionType; use OCA\Files\Sharing\Property\NodeGridViewSharePropertyType; use OCA\Files\Sharing\Source\NodeShareSourceType; +use OCA\Files_Trashbin\Events\MoveToTrashEvent; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -58,6 +59,7 @@ use OCP\Files\Events\Node\BeforeNodeDeletedEvent; use OCP\Files\Events\Node\BeforeNodeRenamedEvent; use OCP\Files\Events\Node\NodeCopiedEvent; +use OCP\Files\Events\Node\NodeDeletedEvent; use OCP\Files\Events\NodeAddedToFavorite; use OCP\Files\Events\NodeRemovedFromFavorite; use OCP\Interaction\RestrictInteractionEvent; @@ -139,6 +141,9 @@ public function register(IRegistrationContext $context): void { $registry->registerPermissionType(NodeShareSourceType::class, Server::get(NodeDownloadSharePermissionType::class)); $registry->markPermissionTypeCompatibleWithPermissionPreset(NodeDownloadSharePermissionType::class, ViewSharePermissionPreset::class); $registry->markPermissionTypeCompatibleWithPermissionPreset(NodeDownloadSharePermissionType::class, EditSharePermissionPreset::class); + + $context->registerEventListener(NodeDeletedEvent::class, NodeShareSourceType::class); + $context->registerEventListener(MoveToTrashEvent::class, NodeShareSourceType::class); } #[\Override] diff --git a/apps/files/lib/Sharing/Source/NodeShareSourceType.php b/apps/files/lib/Sharing/Source/NodeShareSourceType.php index e2b10ca37a3aa..eda0bc37e0f9c 100644 --- a/apps/files/lib/Sharing/Source/NodeShareSourceType.php +++ b/apps/files/lib/Sharing/Source/NodeShareSourceType.php @@ -46,8 +46,6 @@ public function __construct( private ISharingManager $manager, private IFileAccess $fileAccess, ) { - $eventDispatcher->addServiceListener(NodeDeletedEvent::class, self::class); - $eventDispatcher->addServiceListener(MoveToTrashEvent::class, self::class); } #[\Override] diff --git a/apps/sharing/lib/AppInfo/Application.php b/apps/sharing/lib/AppInfo/Application.php index 680e125b05bcc..f7061efe270e9 100644 --- a/apps/sharing/lib/AppInfo/Application.php +++ b/apps/sharing/lib/AppInfo/Application.php @@ -9,12 +9,14 @@ namespace OCA\Sharing\AppInfo; +use OC\Sharing\SharingManager; use OCA\Sharing\Capabilities; use OCA\Sharing\Middleware\ShareApiEnabledMiddleware; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\User\Events\BeforeUserDeletedEvent; final class Application extends App implements IBootstrap { public const string APP_ID = 'sharing'; @@ -27,6 +29,8 @@ public function __construct(array $urlParams = []) { public function register(IRegistrationContext $context): void { $context->registerCapability(Capabilities::class); $context->registerMiddleware(ShareApiEnabledMiddleware::class); + + $context->registerEventListener(BeforeUserDeletedEvent::class, SharingManager::class); } #[\Override] diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index d1410703a1eaa..003b70c962d87 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -2635,6 +2635,14 @@ + + + + + + + + @@ -2968,15 +2976,9 @@ - - - - - - diff --git a/core/AppInfo/Application.php b/core/AppInfo/Application.php index 9c439c677112e..32ee1041c24bb 100644 --- a/core/AppInfo/Application.php +++ b/core/AppInfo/Application.php @@ -44,6 +44,7 @@ use OC\DirectEditing\Listeners\UserDisabledTokenCleanupListener as UserDisabledDirectEditingTokenCleanupListener; use OC\OCM\OCMDiscoveryHandler; use OC\TagManager; +use OCA\Circles\Events\DestroyingCircleEvent; use OCP\Accounts\UserUpdatedEvent; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -55,6 +56,7 @@ use OCP\Config\BeforeAppConfigSetEvent; use OCP\DB\Events\AddMissingIndicesEvent; use OCP\DB\Events\AddMissingPrimaryKeyEvent; +use OCP\Group\Events\BeforeGroupDeletedEvent; use OCP\IAppConfig; use OCP\Interaction\RestrictInteractionEvent; use OCP\Navigation\Events\LoadAdditionalEntriesEvent; @@ -166,6 +168,10 @@ public function register(IRegistrationContext $context): void { if (!Server::get(IAppConfig::class)->getValueBool('files_sharing', 'shareapi_exclude_reshare_from_edit')) { $registry->markPermissionTypeCompatibleWithPermissionPreset(ReshareSharePermissionType::class, EditSharePermissionPreset::class); } + + $context->registerEventListener(BeforeGroupDeletedEvent::class, GroupShareRecipientType::class); + $context->registerEventListener(DestroyingCircleEvent::class, TeamShareRecipientType::class); + $context->registerEventListener(BeforeUserDeletedEvent::class, UserShareRecipientType::class); } #[\Override] diff --git a/core/Sharing/Recipient/GroupShareRecipientType.php b/core/Sharing/Recipient/GroupShareRecipientType.php index 53c1de0629961..2519947c46193 100644 --- a/core/Sharing/Recipient/GroupShareRecipientType.php +++ b/core/Sharing/Recipient/GroupShareRecipientType.php @@ -42,7 +42,6 @@ public function __construct( private readonly IGroupManager $groupManager, private readonly ISharingManager $manager, ) { - $eventDispatcher->addServiceListener(BeforeGroupDeletedEvent::class, self::class); } #[\Override] diff --git a/core/Sharing/Recipient/TeamShareRecipientType.php b/core/Sharing/Recipient/TeamShareRecipientType.php index 61a02fded937b..36a6537781558 100644 --- a/core/Sharing/Recipient/TeamShareRecipientType.php +++ b/core/Sharing/Recipient/TeamShareRecipientType.php @@ -42,7 +42,6 @@ public function __construct( private readonly IDBConnection $dbConnection, private readonly ISharingManager $manager, ) { - $eventDispatcher->addServiceListener(DestroyingCircleEvent::class, self::class); } // CirclesManager class is not registered yet when the class is instantiated. diff --git a/core/Sharing/Recipient/UserShareRecipientType.php b/core/Sharing/Recipient/UserShareRecipientType.php index 0b4010136a4fb..113fb16a8ca8c 100644 --- a/core/Sharing/Recipient/UserShareRecipientType.php +++ b/core/Sharing/Recipient/UserShareRecipientType.php @@ -41,7 +41,6 @@ public function __construct( private readonly IUserManager $userManager, private readonly ISharingManager $manager, ) { - $eventDispatcher->addServiceListener(BeforeUserDeletedEvent::class, self::class); } #[\Override] diff --git a/lib/private/Sharing/SharingManager.php b/lib/private/Sharing/SharingManager.php index a6d36ca28e782..e9bba995337b7 100644 --- a/lib/private/Sharing/SharingManager.php +++ b/lib/private/Sharing/SharingManager.php @@ -78,8 +78,6 @@ public function __construct( ) { $this->randomizer = new Randomizer(); $this->l10n = $l10nFactory->get('sharing'); - - $this->eventDispatcher->addServiceListener(BeforeUserDeletedEvent::class, self::class); } #[\Override] From 40869c5cb4bd1a05a6b0a8b32a520a29e829d47b Mon Sep 17 00:00:00 2001 From: provokateurin Date: Mon, 2 Nov 2026 11:15:45 +0100 Subject: [PATCH 02/23] refactor(Sharing): Tighten more types Signed-off-by: provokateurin --- .../sharing/lib/Command/AddShareRecipient.php | 2 +- apps/sharing/lib/Command/AddShareSource.php | 2 +- apps/sharing/lib/Command/DeleteShare.php | 2 +- apps/sharing/lib/Command/GetShare.php | 2 +- apps/sharing/lib/Command/GetShares.php | 2 +- .../lib/Command/RemoveShareRecipient.php | 2 +- .../sharing/lib/Command/RemoveShareSource.php | 2 +- .../Command/SelectSharePermissionPreset.php | 2 +- .../lib/Command/UpdateSharePermission.php | 2 +- .../lib/Command/UpdateShareProperty.php | 2 +- .../UpdateShareRecipientPermission.php | 2 +- .../Command/UpdateShareRecipientSecret.php | 2 +- apps/sharing/lib/Command/UpdateShareState.php | 2 +- .../lib/Command/UpdateShareUserStatus.php | 2 +- .../lib/Controller/ApiV1Controller.php | 32 ++++++------- apps/sharing/openapi.json | 48 ++++++++++++------- .../tests/Controller/ApiV1ControllerTest.php | 1 - lib/private/Sharing/SharingBackend.php | 23 ++------- lib/unstable/Sharing/ISharingBackend.php | 37 +++++++++----- lib/unstable/Sharing/ISharingManager.php | 7 ++- openapi.json | 48 ++++++++++++------- .../Sharing/AbstractSharingManagerTests.php | 2 + 22 files changed, 129 insertions(+), 97 deletions(-) diff --git a/apps/sharing/lib/Command/AddShareRecipient.php b/apps/sharing/lib/Command/AddShareRecipient.php index e45a347943a1d..94899abcb9f32 100644 --- a/apps/sharing/lib/Command/AddShareRecipient.php +++ b/apps/sharing/lib/Command/AddShareRecipient.php @@ -31,7 +31,7 @@ public function configure(): void { #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - /** @var string $id */ + /** @var non-empty-string $id */ $id = $input->getArgument('id'); /** @var class-string $class */ $class = $input->getArgument('class'); diff --git a/apps/sharing/lib/Command/AddShareSource.php b/apps/sharing/lib/Command/AddShareSource.php index 2a4b37aa8e741..82e7ea492bb3c 100644 --- a/apps/sharing/lib/Command/AddShareSource.php +++ b/apps/sharing/lib/Command/AddShareSource.php @@ -30,7 +30,7 @@ public function configure(): void { #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - /** @var string $id */ + /** @var non-empty-string $id */ $id = $input->getArgument('id'); /** @var class-string $class */ $class = $input->getArgument('class'); diff --git a/apps/sharing/lib/Command/DeleteShare.php b/apps/sharing/lib/Command/DeleteShare.php index d921b9a4ad296..c37697899419c 100644 --- a/apps/sharing/lib/Command/DeleteShare.php +++ b/apps/sharing/lib/Command/DeleteShare.php @@ -29,7 +29,7 @@ public function configure(): void { #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - /** @var string $id */ + /** @var non-empty-string $id */ $id = $input->getArgument('id'); $this->applyActor($input); diff --git a/apps/sharing/lib/Command/GetShare.php b/apps/sharing/lib/Command/GetShare.php index 1f27891b6a7e8..fb1ddf9f99d15 100644 --- a/apps/sharing/lib/Command/GetShare.php +++ b/apps/sharing/lib/Command/GetShare.php @@ -26,7 +26,7 @@ public function configure(): void { #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - /** @var string $id */ + /** @var non-empty-string $id */ $id = $input->getArgument('id'); return $this->wrapExecution($input, $output, fn (): Share => $this->manager->getShare($this->accessContext, $id)); diff --git a/apps/sharing/lib/Command/GetShares.php b/apps/sharing/lib/Command/GetShares.php index 01fafd9ef741b..9b0097d449237 100644 --- a/apps/sharing/lib/Command/GetShares.php +++ b/apps/sharing/lib/Command/GetShares.php @@ -66,7 +66,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { } } - /** @var ?string $lastShareID */ + /** @var ?non-empty-string $lastShareID */ $lastShareID = $input->getOption('last-share-id'); /** @var ?string $limit */ $limit = $input->getOption('limit'); diff --git a/apps/sharing/lib/Command/RemoveShareRecipient.php b/apps/sharing/lib/Command/RemoveShareRecipient.php index 50873cfb07207..1227087263df0 100644 --- a/apps/sharing/lib/Command/RemoveShareRecipient.php +++ b/apps/sharing/lib/Command/RemoveShareRecipient.php @@ -31,7 +31,7 @@ public function configure(): void { #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - /** @var string $id */ + /** @var non-empty-string $id */ $id = $input->getArgument('id'); /** @var class-string $class */ $class = $input->getArgument('class'); diff --git a/apps/sharing/lib/Command/RemoveShareSource.php b/apps/sharing/lib/Command/RemoveShareSource.php index 732d73176fc99..445cd0646f844 100644 --- a/apps/sharing/lib/Command/RemoveShareSource.php +++ b/apps/sharing/lib/Command/RemoveShareSource.php @@ -30,7 +30,7 @@ public function configure(): void { #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - /** @var string $id */ + /** @var non-empty-string $id */ $id = $input->getArgument('id'); /** @var class-string $class */ $class = $input->getArgument('class'); diff --git a/apps/sharing/lib/Command/SelectSharePermissionPreset.php b/apps/sharing/lib/Command/SelectSharePermissionPreset.php index 6932961656368..bb21050d0676f 100644 --- a/apps/sharing/lib/Command/SelectSharePermissionPreset.php +++ b/apps/sharing/lib/Command/SelectSharePermissionPreset.php @@ -28,7 +28,7 @@ public function configure(): void { #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - /** @var string $id */ + /** @var non-empty-string $id */ $id = $input->getArgument('id'); /** @var class-string $permissionPresetClass */ $permissionPresetClass = $input->getArgument('permission-preset'); diff --git a/apps/sharing/lib/Command/UpdateSharePermission.php b/apps/sharing/lib/Command/UpdateSharePermission.php index d7b0067715269..a02ecb48fceca 100644 --- a/apps/sharing/lib/Command/UpdateSharePermission.php +++ b/apps/sharing/lib/Command/UpdateSharePermission.php @@ -30,7 +30,7 @@ public function configure(): void { #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - /** @var string $id */ + /** @var non-empty-string $id */ $id = $input->getArgument('id'); /** @var class-string $class */ $class = $input->getArgument('class'); diff --git a/apps/sharing/lib/Command/UpdateShareProperty.php b/apps/sharing/lib/Command/UpdateShareProperty.php index b197355e5037a..fc0f40689f989 100644 --- a/apps/sharing/lib/Command/UpdateShareProperty.php +++ b/apps/sharing/lib/Command/UpdateShareProperty.php @@ -30,7 +30,7 @@ public function configure(): void { #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - /** @var string $id */ + /** @var non-empty-string $id */ $id = $input->getArgument('id'); /** @var class-string $class */ $class = $input->getArgument('class'); diff --git a/apps/sharing/lib/Command/UpdateShareRecipientPermission.php b/apps/sharing/lib/Command/UpdateShareRecipientPermission.php index 09e7c7849c580..456d6c06eef4e 100644 --- a/apps/sharing/lib/Command/UpdateShareRecipientPermission.php +++ b/apps/sharing/lib/Command/UpdateShareRecipientPermission.php @@ -35,7 +35,7 @@ public function configure(): void { #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - /** @var string $id */ + /** @var non-empty-string $id */ $id = $input->getArgument('id'); /** @var class-string $permissionClass */ $permissionClass = $input->getArgument('permission-class'); diff --git a/apps/sharing/lib/Command/UpdateShareRecipientSecret.php b/apps/sharing/lib/Command/UpdateShareRecipientSecret.php index bd3e0998640c7..66a326de07087 100644 --- a/apps/sharing/lib/Command/UpdateShareRecipientSecret.php +++ b/apps/sharing/lib/Command/UpdateShareRecipientSecret.php @@ -32,7 +32,7 @@ public function configure(): void { #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - /** @var string $id */ + /** @var non-empty-string $id */ $id = $input->getArgument('id'); /** @var class-string $class */ $class = $input->getArgument('class'); diff --git a/apps/sharing/lib/Command/UpdateShareState.php b/apps/sharing/lib/Command/UpdateShareState.php index ac34b885664a3..1b34b8661d131 100644 --- a/apps/sharing/lib/Command/UpdateShareState.php +++ b/apps/sharing/lib/Command/UpdateShareState.php @@ -28,7 +28,7 @@ public function configure(): void { #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - /** @var string $id */ + /** @var non-empty-string $id */ $id = $input->getArgument('id'); /** @var string $state */ $state = $input->getArgument('state'); diff --git a/apps/sharing/lib/Command/UpdateShareUserStatus.php b/apps/sharing/lib/Command/UpdateShareUserStatus.php index 9dd7d719f9d42..66d53903e765d 100644 --- a/apps/sharing/lib/Command/UpdateShareUserStatus.php +++ b/apps/sharing/lib/Command/UpdateShareUserStatus.php @@ -28,7 +28,7 @@ public function configure(): void { #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { - /** @var string $id */ + /** @var non-empty-string $id */ $id = $input->getArgument('id'); /** @var string $userStatus */ $userStatus = $input->getArgument('user-status'); diff --git a/apps/sharing/lib/Controller/ApiV1Controller.php b/apps/sharing/lib/Controller/ApiV1Controller.php index 1e5b4d42ad3b8..c37854c36bc06 100644 --- a/apps/sharing/lib/Controller/ApiV1Controller.php +++ b/apps/sharing/lib/Controller/ApiV1Controller.php @@ -83,11 +83,11 @@ public function __construct( /** * Search for recipients that can be added to a share. * - * @param ?list> $filterRecipientTypeClasses Type classes of recipients to filter by + * @param ?non-empty-list> $filterRecipientTypeClasses Type classes of recipients to filter by * @param string $query The query to search for, if the query is empty, recommended recipients will be returned * @param int<1, 100> $limit The maximum number of participants * @param non-negative-int $offset The offset of the participants - * @param ?string $id If provided, recipients that are already part of the share will not be returned. + * @param ?non-empty-string $id If provided, recipients that are already part of the share will not be returned. * @return DataResponse, array{Link?: string}>|DataResponse * * 200: Recipients returned @@ -185,7 +185,7 @@ public function createShare(): DataResponse { /** * Update the state of a share. * - * @param string $id ID of the share + * @param non-empty-string $id ID of the share * @param SharingState $state New state of the share * @return DataResponse|DataResponse * @@ -226,7 +226,7 @@ public function updateShareState(string $id, string $state): DataResponse { /** * Update the user status for a share. * - * @param string $id ID of the share + * @param non-empty-string $id ID of the share * @param SharingUserStatus $userStatus New user status for the share * @return DataResponse|DataResponse * @@ -264,7 +264,7 @@ public function updateShareUserStatus(string $id, string $userStatus): DataRespo /** * Add a new source to a share. * - * @param string $id ID of the share + * @param non-empty-string $id ID of the share * @param class-string $class Type class of the source * @param non-empty-string $value Value of the source * @return DataResponse|DataResponse @@ -302,7 +302,7 @@ public function addShareSource(string $id, string $class, string $value): DataRe /** * Remove an existing source from a share. * - * @param string $id ID of the share + * @param non-empty-string $id ID of the share * @param class-string $class Type class of the source * @param non-empty-string $value Value of the source * @return DataResponse|DataResponse @@ -337,7 +337,7 @@ public function removeShareSource(string $id, string $class, string $value): Dat /** * Add a new recipient to a share. * - * @param string $id ID of the share + * @param non-empty-string $id ID of the share * @param class-string $class Type class of the recipient * @param non-empty-string $value Value of the recipient * @param ?non-empty-string $instance Instance of the recipient @@ -376,7 +376,7 @@ public function addShareRecipient(string $id, string $class, string $value, ?str /** * Remove an existing recipient from a share. * - * @param string $id ID of the share + * @param non-empty-string $id ID of the share * @param class-string $class Type class of the recipient * @param non-empty-string $value Value of the recipient * @param ?non-empty-string $instance Instance of the recipient @@ -412,7 +412,7 @@ public function removeShareRecipient(string $id, string $class, string $value, ? /** * Update the secret of a recipient. * - * @param string $id ID of the share + * @param non-empty-string $id ID of the share * @param class-string $class Type class of the recipient * @param non-empty-string $value Value of the recipient * @param ?non-empty-string $instance Instance of the recipient @@ -452,7 +452,7 @@ public function updateShareRecipientSecret(string $id, string $class, string $va /** * Update a property of a share. * - * @param string $id ID of the share + * @param non-empty-string $id ID of the share * @param class-string $class Type class of the property * @param ?string $value Value of the property * @return DataResponse|DataResponse @@ -490,7 +490,7 @@ public function updateShareProperty(string $id, string $class, ?string $value): /** * Update a permission of a share. * - * @param string $id ID of the share + * @param non-empty-string $id ID of the share * @param class-string $class Type class of the permission * @param bool $enabled Enabled state of the permission * @return DataResponse|DataResponse @@ -528,7 +528,7 @@ public function updateSharePermission(string $id, string $class, bool $enabled): /** * Update a permission for a recipient of a share. * - * @param string $id ID of the share + * @param non-empty-string $id ID of the share * @param class-string $recipientClass Type class of the recipient * @param non-empty-string $recipientValue Value of the recipient * @param ?non-empty-string $recipientInstance Instance of the recipient @@ -566,7 +566,7 @@ public function updateShareRecipientPermission(string $id, string $recipientClas /** * Select a permission preset for a share. * - * @param string $id ID of the share + * @param non-empty-string $id ID of the share * @param class-string $permissionPresetClass New permission preset of the share * @return DataResponse|DataResponse * @@ -601,7 +601,7 @@ public function selectSharePermissionPreset(string $id, string $permissionPreset /** * Delete a share. * - * @param string $id ID of the share + * @param non-empty-string $id ID of the share * @return DataResponse, array{}>|DataResponse * * 204: Share deleted @@ -634,7 +634,7 @@ public function deleteShare(string $id): DataResponse { /** * Get a share. * - * @param string $id ID of the share + * @param non-empty-string $id ID of the share * @param ?string $secret Secret of the share * @param array, mixed> $arguments Arguments for accessing the share * @return DataResponse|DataResponse @@ -675,7 +675,7 @@ public function getShare(string $id, ?string $secret = null, array $arguments = * @param ?non-empty-string $filterSourceTypeValue Source type value to filter by. * @param ?SharingState $filterState State to filter by. * @param ?SharingUserStatus $filterUserStatus User status to filter by. - * @param ?string $lastShareID The ID of the previous share. This is used as an offset and only shares with higher IDs are returned. + * @param ?non-empty-string $lastShareID The ID of the previous share. This is used as an offset and only shares with higher IDs are returned. * @param int<1, 100> $limit The number of shares to return. * @return DataResponse, array{}>|DataResponse * diff --git a/apps/sharing/openapi.json b/apps/sharing/openapi.json index 0a70734e06617..ed841886e9bd3 100644 --- a/apps/sharing/openapi.json +++ b/apps/sharing/openapi.json @@ -649,7 +649,8 @@ "items": { "type": "string", "minLength": 1 - } + }, + "minItems": 1 } }, { @@ -691,7 +692,8 @@ "schema": { "type": "string", "nullable": true, - "default": null + "default": null, + "minLength": 1 } }, { @@ -1029,7 +1031,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -1236,7 +1239,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -1420,7 +1424,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -1606,7 +1611,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -1816,7 +1822,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -2002,7 +2009,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -2228,7 +2236,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -2441,7 +2450,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -2654,7 +2664,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -2885,7 +2896,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -3063,7 +3075,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -3251,7 +3264,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -3407,7 +3421,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -3553,7 +3568,8 @@ "description": "The ID of the previous share. This is used as an offset and only shares with higher IDs are returned.", "schema": { "type": "string", - "nullable": true + "nullable": true, + "minLength": 1 } }, { diff --git a/apps/sharing/tests/Controller/ApiV1ControllerTest.php b/apps/sharing/tests/Controller/ApiV1ControllerTest.php index d439badd0a81a..03b7093e26113 100644 --- a/apps/sharing/tests/Controller/ApiV1ControllerTest.php +++ b/apps/sharing/tests/Controller/ApiV1ControllerTest.php @@ -149,7 +149,6 @@ protected function removeShareRecipient(ShareAccessContext $accessContext, Share #[Override] protected function updateShareRecipientSecret(ShareAccessContext $accessContext, Share $share, ShareRecipient $recipient, string $secret): array { - /** @psalm-suppress ArgumentTypeCoercion */ /** @var SharingShare */ return $this->executeRequest($accessContext, fn (ApiV1Controller $controller): DataResponse => $controller->updateShareRecipientSecret($share->id, $recipient->class, $recipient->value, $recipient->instance, $secret)); } diff --git a/lib/private/Sharing/SharingBackend.php b/lib/private/Sharing/SharingBackend.php index 17d4122975827..84f793d404573 100644 --- a/lib/private/Sharing/SharingBackend.php +++ b/lib/private/Sharing/SharingBackend.php @@ -94,7 +94,7 @@ public function onOwnerDeleted(ShareUser $owner): array { $result = $qb->executeQuery(); - /** @var list $ids */ + /** @var list $ids */ $ids = $result->fetchFirstColumn(); if ($ids === []) { return []; @@ -206,7 +206,7 @@ public function onSourceDeleted(ShareSource $source): array { ->andWhere($qb->expr()->eq('source_value', $qb->createNamedParameter($source->value))) ->executeQuery(); - /** @var list $ids */ + /** @var list $ids */ $ids = $result->fetchFirstColumn(); if ($ids === []) { return []; @@ -300,7 +300,7 @@ public function onRecipientDeleted(ShareRecipient $recipient): array { ->andWhere($this->isNullOrEqual($qb, 'recipient_instance_id', $this->instanceMapper->getInstanceId($recipient->instance))) ->executeQuery(); - /** @var list $ids */ + /** @var list $ids */ $ids = $result->fetchFirstColumn(); if ($ids === []) { return []; @@ -334,7 +334,7 @@ public function onInitiatorDeleted(ShareUser $initiator): array { $result = $qb->executeQuery(); - /** @var list $ids */ + /** @var list $ids */ $ids = $result->fetchFirstColumn(); if ($ids === []) { return []; @@ -573,21 +573,6 @@ public function getShares( return $this->list($accessContext, null, $filterSourceTypeClass, $filterSourceTypeValue, $filterState, $filterUserStatus, $lastShareID, $limit); } - #[\Override] - public function hasShare(string $id): bool { - $this->assertInTransaction(); - - $qb = $this->connection->getQueryBuilder(); - - $result = $qb - ->select('id') - ->from('sharing_share') - ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))) - ->executeQuery(); - - return $result->fetchOne() !== false; - } - #[\Override] public function getShareOwner(string $id): ShareUser { $this->assertInTransaction(); diff --git a/lib/unstable/Sharing/ISharingBackend.php b/lib/unstable/Sharing/ISharingBackend.php index 410c591a123e8..141c8a91a1979 100644 --- a/lib/unstable/Sharing/ISharingBackend.php +++ b/lib/unstable/Sharing/ISharingBackend.php @@ -28,6 +28,7 @@ interface ISharingBackend { /** * Create a new share. * + * @param non-empty-string $id * @experimental 35.0.0 */ public function createShare(string $id, ShareUser $owner, \DateTimeImmutable $lastUpdated): void; @@ -35,7 +36,7 @@ public function createShare(string $id, ShareUser $owner, \DateTimeImmutable $la /** * Perform all updates when the owner was deleted. * - * @return list + * @return list * @experimental 35.0.0 */ public function onOwnerDeleted(ShareUser $owner): array; @@ -43,6 +44,7 @@ public function onOwnerDeleted(ShareUser $owner): array; /** * Update the state of a share. * + * @param non-empty-string $id * @throws ShareNotFoundException * @experimental 35.0.0 */ @@ -51,6 +53,8 @@ public function updateShareState(string $id, ShareState $state): void; /** * Update the user status for a share. * + * @param non-empty-string $id + * @param non-empty-string $userId * @experimental 35.0.0 */ public function updateShareUserStatus(string $id, string $userId, ShareUserStatus $userStatus): void; @@ -58,6 +62,7 @@ public function updateShareUserStatus(string $id, string $userId, ShareUserStatu /** * Add a new source to a share. * + * @param non-empty-string $id * @throws ShareInvalidException * @throws ShareNotFoundException * @experimental 35.0.0 @@ -67,6 +72,7 @@ public function addShareSource(string $id, ShareSource $source): void; /** * Remove an existing source from a share. * + * @param non-empty-string $id * @throws ShareNotFoundException * @experimental 35.0.0 */ @@ -75,7 +81,7 @@ public function removeShareSource(string $id, ShareSource $source): void; /** * Perform all updates when the source was deleted. * - * @return list + * @return list * @experimental 35.0.0 */ public function onSourceDeleted(ShareSource $source): array; @@ -83,6 +89,7 @@ public function onSourceDeleted(ShareSource $source): array; /** * Add a new recipient to a share. * + * @param non-empty-string $id * @throws ShareInvalidException * @throws ShareNotFoundException * @experimental 35.0.0 @@ -92,6 +99,7 @@ public function addShareRecipient(string $id, ShareRecipient $recipient): void; /** * Remove an existing recipient from a share. * + * @param non-empty-string $id * @throws ShareNotFoundException * @experimental 35.0.0 */ @@ -100,7 +108,7 @@ public function removeShareRecipient(string $id, ShareRecipient $recipient): voi /** * Perform all updates when the recipient was deleted. * - * @return list + * @return list * @experimental 35.0.0 */ public function onRecipientDeleted(ShareRecipient $recipient): array; @@ -108,7 +116,7 @@ public function onRecipientDeleted(ShareRecipient $recipient): array; /** * Perform all updates when the initiator was deleted. * - * @return list + * @return list * @experimental 35.0.0 */ public function onInitiatorDeleted(ShareUser $initiator): array; @@ -116,6 +124,8 @@ public function onInitiatorDeleted(ShareUser $initiator): array; /** * Update the secret of a recipient. * + * @param non-empty-string $id + * @param non-empty-string $secret * @throws ShareNotFoundException * @experimental 35.0.0 */ @@ -124,6 +134,7 @@ public function updateShareRecipientSecret(string $id, ShareRecipient $recipient /** * Update a property of a share. * + * @param non-empty-string $id * @throws ShareNotFoundException * @experimental 35.0.0 */ @@ -132,6 +143,7 @@ public function updateShareProperty(string $id, ShareProperty $property): ?strin /** * Update a permission of a share. * + * @param non-empty-string $id * @throws ShareNotFoundException * @experimental 35.0.0 */ @@ -140,6 +152,7 @@ public function updateSharePermission(string $id, SharePermission $permission): /** * Update a permission for a recipient of a share. * + * @param non-empty-string $id * @throws ShareNotFoundException * @experimental 35.0.0 */ @@ -148,6 +161,7 @@ public function updateShareRecipientPermission(string $id, ShareRecipient $recip /** * Select a permission preset for a share. * + * @param non-empty-string $id * @param class-string $permissionPresetClass * @throws ShareNotFoundException * @experimental 35.0.0 @@ -157,6 +171,7 @@ public function selectSharePermissionPreset(string $id, string $permissionPreset /** * Delete a share. * + * @param non-empty-string $id * @throws ShareNotFoundException * @experimental 35.0.0 */ @@ -165,6 +180,7 @@ public function deleteShare(string $id): void; /** * Get a share. * + * @param non-empty-string $id * @throws ShareNotFoundException * @experimental 35.0.0 */ @@ -175,22 +191,17 @@ public function getShare(ShareAccessContext $accessContext, string $id): Share; * * @param ?class-string $filterSourceTypeClass * @param ?non-empty-string $filterSourceTypeValue + * @param ?non-empty-string $lastShareID * @param ?positive-int $limit * @return list * @experimental 35.0.0 */ public function getShares(ShareAccessContext $accessContext, ?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?ShareState $filterState, ?ShareUserStatus $filterUserStatus, ?string $lastShareID, ?int $limit): array; - /** - * Check if a share ID belongs to this backend. - * - * @experimental 35.0.0 - */ - public function hasShare(string $id): bool; - /** * Get the owner of a share. * + * @param non-empty-string $id * @throws ShareNotFoundException * @experimental 35.0.0 */ @@ -199,7 +210,7 @@ public function getShareOwner(string $id): ShareUser; /** * Set the last updated timestamp for multiple shares. * - * @param non-empty-list $ids + * @param non-empty-list $ids * @throws ShareNotFoundException * @experimental 35.0.0 */ @@ -219,7 +230,7 @@ public function ensureDefaults(array $shares): array; * * "shared with" includes both shares owned by the user, and reshares initiated by the user * - * @param ?list> $filterRecipientTypeClasses + * @param ?non-empty-list> $filterRecipientTypeClasses * @param null|non-empty-string $notInShare * @param non-negative-int $count * @return list diff --git a/lib/unstable/Sharing/ISharingManager.php b/lib/unstable/Sharing/ISharingManager.php index 044cf78f0202b..4fb6592cc73b8 100644 --- a/lib/unstable/Sharing/ISharingManager.php +++ b/lib/unstable/Sharing/ISharingManager.php @@ -29,10 +29,10 @@ interface ISharingManager { /** * Search for recipients that can be added to a share. * - * @param ?list> $filterRecipientTypeClasses + * @param ?non-empty-list> $filterRecipientTypeClasses * @param positive-int $limit * @param non-negative-int $offset - * @param ?Share $excludeShare If provided, recipients that are already part of the share will not be returned. + * @param ?Share $forShare If provided, recipients that are already part of the share will not be returned. * @return list * @throws ShareNotFoundException * @experimental 35.0.0 @@ -152,6 +152,7 @@ public function onInitiatorDeleted(ShareAccessContext $accessContext, ShareUser /** * Update the secret of a recipient. * + * @param non-empty-string $secret * @throws ShareInvalidException * @throws ShareNotFoundException * @throws ShareOperationForbiddenException @@ -210,6 +211,7 @@ public function deleteShare(ShareAccessContext $accessContext, Share $share): vo /** * Get a share. * + * @param non-empty-string $id * @throws ShareNotFoundException * @experimental 35.0.0 */ @@ -220,6 +222,7 @@ public function getShare(ShareAccessContext $accessContext, string $id): Share; * * @param ?class-string $filterSourceTypeClass * @param ?non-empty-string $filterSourceTypeValue + * @param ?non-empty-string $lastShareID * @param ?positive-int $limit * @return list * @experimental 35.0.0 diff --git a/openapi.json b/openapi.json index a1f9d5eb551c0..bfa180ad69182 100644 --- a/openapi.json +++ b/openapi.json @@ -38440,7 +38440,8 @@ "items": { "type": "string", "minLength": 1 - } + }, + "minItems": 1 } }, { @@ -38482,7 +38483,8 @@ "schema": { "type": "string", "nullable": true, - "default": null + "default": null, + "minLength": 1 } }, { @@ -38820,7 +38822,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -39027,7 +39030,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -39211,7 +39215,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -39397,7 +39402,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -39607,7 +39613,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -39793,7 +39800,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -40019,7 +40027,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -40232,7 +40241,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -40445,7 +40455,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -40676,7 +40687,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -40854,7 +40866,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -41042,7 +41055,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -41198,7 +41212,8 @@ "description": "ID of the share", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 } }, { @@ -41344,7 +41359,8 @@ "description": "The ID of the previous share. This is used as an offset and only shares with higher IDs are returned.", "schema": { "type": "string", - "nullable": true + "nullable": true, + "minLength": 1 } }, { diff --git a/tests/lib/Sharing/AbstractSharingManagerTests.php b/tests/lib/Sharing/AbstractSharingManagerTests.php index df4ec3fe5cd35..ea32dc7bd4c42 100644 --- a/tests/lib/Sharing/AbstractSharingManagerTests.php +++ b/tests/lib/Sharing/AbstractSharingManagerTests.php @@ -88,6 +88,7 @@ abstract protected function addShareRecipient(ShareAccessContext $accessContext, abstract protected function removeShareRecipient(ShareAccessContext $accessContext, Share $share, ShareRecipient $recipient): array; /** + * @param non-empty-string $secret * @return SharingShare */ abstract protected function updateShareRecipientSecret(ShareAccessContext $accessContext, Share $share, ShareRecipient $recipient, string $secret): array; @@ -117,6 +118,7 @@ abstract protected function selectSharePermissionPreset(ShareAccessContext $acce abstract protected function deleteShare(ShareAccessContext $accessContext, Share $share): void; /** + * @param non-empty-string $id * @return SharingShare */ abstract protected function getShare(ShareAccessContext $accessContext, string $id): array; From ac7453fb3fe858646a2822ce21bced32dcf18a0d Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 30 Sep 2026 10:00:32 +0200 Subject: [PATCH 03/23] refactor(files_sharing): Set expiration date time to 23:59:59 only in the controller Signed-off-by: provokateurin --- .../lib/Controller/ShareAPIController.php | 2 ++ .../Controller/ShareAPIControllerTest.php | 6 ++--- lib/private/Share20/Manager.php | 1 - tests/lib/Share20/ManagerTest.php | 23 ++++--------------- 4 files changed, 9 insertions(+), 23 deletions(-) diff --git a/apps/files_sharing/lib/Controller/ShareAPIController.php b/apps/files_sharing/lib/Controller/ShareAPIController.php index 5a9317b772cc7..5bc9f727a1a63 100644 --- a/apps/files_sharing/lib/Controller/ShareAPIController.php +++ b/apps/files_sharing/lib/Controller/ShareAPIController.php @@ -706,6 +706,7 @@ public function createShare( if ($expireDate !== '') { try { $expireDateTime = $this->parseDate($expireDate); + $expireDateTime->setTime(23, 59, 59); $share->setExpirationDate($expireDateTime); } catch (\Exception $e) { throw new OCSNotFoundException($e->getMessage(), $e); @@ -1385,6 +1386,7 @@ public function updateShare( } elseif ($expireDate !== null) { try { $expireDateTime = $this->parseDate($expireDate); + $expireDateTime->setTime(23, 59, 59); $share->setExpirationDate($expireDateTime); } catch (\Exception $e) { throw new OCSBadRequestException($e->getMessage(), $e); diff --git a/apps/files_sharing/tests/Controller/ShareAPIControllerTest.php b/apps/files_sharing/tests/Controller/ShareAPIControllerTest.php index 11bf9c50abf83..865287cdaf04f 100644 --- a/apps/files_sharing/tests/Controller/ShareAPIControllerTest.php +++ b/apps/files_sharing/tests/Controller/ShareAPIControllerTest.php @@ -2196,7 +2196,7 @@ public function testCreateShareValidExpireDate(): void { $this->shareManager->expects($this->once())->method('createShare')->with( $this->callback(function (IShare $share) use ($path) { $date = new \DateTime('2000-01-01'); - $date->setTime(0, 0, 0); + $date->setTime(23, 59, 59); return $share->getNode() === $path && $share->getShareType() === IShare::TYPE_LINK @@ -2749,7 +2749,7 @@ public function testUpdateLinkShareSet(): void { $this->shareManager->expects($this->once())->method('updateShare')->with( $this->callback(function (IShare $share) { $date = new \DateTime('2000-01-01'); - $date->setTime(0, 0, 0); + $date->setTime(23, 59, 59); return $share->getPermissions() === (Constants::PERMISSION_READ | Constants::PERMISSION_CREATE | Constants::PERMISSION_UPDATE | Constants::PERMISSION_DELETE) && $share->getPassword() === 'password' @@ -3233,7 +3233,7 @@ public function testUpdateLinkShareExpireDateDoesNotChangeOther(): void { $this->shareManager->expects($this->once())->method('updateShare')->with( $this->callback(function (IShare $share) { $date = new \DateTime('2010-12-23'); - $date->setTime(0, 0, 0); + $date->setTime(23, 59, 59); return $share->getPermissions() === Constants::PERMISSION_ALL && $share->getPassword() === 'password' diff --git a/lib/private/Share20/Manager.php b/lib/private/Share20/Manager.php index 4e4a0611dea3b..a57b44858ff26 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -298,7 +298,6 @@ protected function validateExpirationDate( if (!$share->getNoExpirationDate() || $isEnforced) { if ($expirationDate !== null) { $expirationDate->setTimezone($this->dateTimeZone->getTimeZone()); - $expirationDate->setTime(23, 59, 59); $date = new \DateTime('now', $this->dateTimeZone->getTimeZone()); $date->setTime(0, 0, 0); diff --git a/tests/lib/Share20/ManagerTest.php b/tests/lib/Share20/ManagerTest.php index 89ba01a5ee65d..efd385104e091 100644 --- a/tests/lib/Share20/ManagerTest.php +++ b/tests/lib/Share20/ManagerTest.php @@ -1585,9 +1585,6 @@ public function testValidateExpirationDateInternalEnforceValid($shareType): void $future->add(new \DateInterval('P2D')); $future->setTime(1, 2, 3); - $expected = clone $future; - $expected->setTime(23, 59, 59); - $share = $this->manager->newShare(); $share->setShareType($shareType); $share->setExpirationDate($future); @@ -1610,7 +1607,7 @@ public function testValidateExpirationDateInternalEnforceValid($shareType): void self::invokePrivate($this->manager, 'validateExpirationDateInternal', [$share]); - $this->assertEquals($expected, $share->getExpirationDate()); + $this->assertEquals($future, $share->getExpirationDate()); } #[DataProvider('validateExpirationDateInternalProvider')] @@ -1619,16 +1616,13 @@ public function testValidateExpirationDateInternalNoDefault($shareType): void { $date->add(new \DateInterval('P5D')); $date->setTime(1, 2, 3); - $expected = clone $date; - $expected->setTime(23, 59, 59); - $share = $this->manager->newShare(); $share->setShareType($shareType); $share->setExpirationDate($date); self::invokePrivate($this->manager, 'validateExpirationDateInternal', [$share]); - $this->assertEquals($expected, $share->getExpirationDate()); + $this->assertEquals($date, $share->getExpirationDate()); } #[DataProvider('validateExpirationDateInternalProvider')] @@ -1680,9 +1674,6 @@ public function testValidateExpirationDateInternalDefault($shareType): void { $future->add(new \DateInterval('P5D')); $future->setTime(1, 2, 3); - $expected = clone $future; - $expected->setTime(23, 59, 59); - $share = $this->manager->newShare(); $share->setShareType($shareType); $share->setExpirationDate($future); @@ -1705,7 +1696,7 @@ public function testValidateExpirationDateInternalDefault($shareType): void { self::invokePrivate($this->manager, 'validateExpirationDateInternal', [$share]); - $this->assertEquals($expected, $share->getExpirationDate()); + $this->assertEquals($future, $share->getExpirationDate()); } #[DataProvider('validateExpirationDateInternalProvider')] @@ -1856,9 +1847,6 @@ public function testValidateExpirationDateEnforceValid(): void { $future->add(new \DateInterval('P2D')); $future->setTime(1, 2, 3); - $expected = clone $future; - $expected->setTime(23, 59, 59); - $share = $this->manager->newShare(); $share->setExpirationDate($future); @@ -1875,7 +1863,7 @@ public function testValidateExpirationDateEnforceValid(): void { self::invokePrivate($this->manager, 'validateExpirationDateLink', [$share]); - $this->assertEquals($expected, $share->getExpirationDate()); + $this->assertEquals($future, $share->getExpirationDate()); } public function testValidateExpirationDateNoDefault(): void { @@ -1884,7 +1872,6 @@ public function testValidateExpirationDateNoDefault(): void { $date->setTime(1, 2, 3); $expected = clone $date; - $expected->setTime(23, 59, 59); $expected->setTimezone(new \DateTimeZone(date_default_timezone_get())); $share = $this->manager->newShare(); @@ -1936,7 +1923,6 @@ public function testValidateExpirationDateDefault(): void { $future->setTime(1, 2, 3); $expected = clone $future; - $expected->setTime(23, 59, 59); $expected->setTimezone(new \DateTimeZone(date_default_timezone_get())); $share = $this->manager->newShare(); @@ -1966,7 +1952,6 @@ public function testValidateExpirationNegativeOffsetTimezone(): void { $expected = clone $future; $expected->setTimezone($this->timezone); - $expected->setTime(23, 59, 59); $expected->setTimezone(new \DateTimeZone(date_default_timezone_get())); $share = $this->manager->newShare(); From 8582ba8209eb719174792e99d6bfc29167eeacf9 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 2 Sep 2026 09:40:44 +0200 Subject: [PATCH 04/23] feat(IShare): Add getProviderId() Signed-off-by: provokateurin --- lib/private/Share20/Share.php | 9 +++++++++ lib/public/Share/IShare.php | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/lib/private/Share20/Share.php b/lib/private/Share20/Share.php index 35c70a1464bfc..e7e2c1e92893e 100644 --- a/lib/private/Share20/Share.php +++ b/lib/private/Share20/Share.php @@ -123,6 +123,15 @@ public function setProviderId(string $id): self { return $this; } + #[Override] + public function getProviderId(): string { + if ($this->providerId === null) { + throw new \UnexpectedValueException; + } + + return $this->providerId; + } + #[Override] public function setNode(Node $node): self { $this->fileId = null; diff --git a/lib/public/Share/IShare.php b/lib/public/Share/IShare.php index 683b67d5dcdb6..d1e1954f7f057 100644 --- a/lib/public/Share/IShare.php +++ b/lib/public/Share/IShare.php @@ -155,6 +155,13 @@ public function getFullId(): string; */ public function setProviderId(string $id): self; + /** + * Get the provider id. + * + * @since 35.0.0 + */ + public function getProviderId(): string; + /** * Set the node of the file/folder that is shared * From c3ca67b7bee848b81459d930a645af3bfb1a5e35 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 2 Sep 2026 10:38:43 +0200 Subject: [PATCH 05/23] feat(Share20): Dispatch ShareUpdatedEvent and ShareRestoredEvent Signed-off-by: provokateurin --- build/rector-strict.php | 2 + lib/composer/composer/autoload_classmap.php | 2 + lib/composer/composer/autoload_static.php | 2 + lib/private/Share20/Manager.php | 13 ++++++- .../Share/Events/ShareRestoredEvent.php | 37 +++++++++++++++++++ lib/public/Share/Events/ShareUpdatedEvent.php | 37 +++++++++++++++++++ psalm-strict.xml | 2 + tests/lib/Share20/ManagerTest.php | 3 +- 8 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 lib/public/Share/Events/ShareRestoredEvent.php create mode 100644 lib/public/Share/Events/ShareUpdatedEvent.php diff --git a/build/rector-strict.php b/build/rector-strict.php index a4457f40dc22d..638bb388946d2 100644 --- a/build/rector-strict.php +++ b/build/rector-strict.php @@ -63,6 +63,8 @@ $nextcloudDir . '/lib/public/SystemReport', $nextcloudDir . '/lib/private/SystemReport', $nextcloudDir . '/tests/lib/SystemReport', + $nextcloudDir . '/lib/public/Share/Events/ShareUpdatedEvent.php', + $nextcloudDir . '/lib/public/Share/Events/ShareRestoredEvent.php', ]) ->withAutoloadPaths([ // ensure rector properly autoload the public interfaces diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 09f53637de36f..e1a87f95893d5 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -953,7 +953,9 @@ 'OCP\\Share\\Events\\ShareDeletedEvent' => $baseDir . '/lib/public/Share/Events/ShareDeletedEvent.php', 'OCP\\Share\\Events\\ShareDeletedFromSelfEvent' => $baseDir . '/lib/public/Share/Events/ShareDeletedFromSelfEvent.php', 'OCP\\Share\\Events\\ShareMovedEvent' => $baseDir . '/lib/public/Share/Events/ShareMovedEvent.php', + 'OCP\\Share\\Events\\ShareRestoredEvent' => $baseDir . '/lib/public/Share/Events/ShareRestoredEvent.php', 'OCP\\Share\\Events\\ShareTransferredEvent' => $baseDir . '/lib/public/Share/Events/ShareTransferredEvent.php', + 'OCP\\Share\\Events\\ShareUpdatedEvent' => $baseDir . '/lib/public/Share/Events/ShareUpdatedEvent.php', 'OCP\\Share\\Events\\VerifyMountPointEvent' => $baseDir . '/lib/public/Share/Events/VerifyMountPointEvent.php', 'OCP\\Share\\Exceptions\\AlreadySharedException' => $baseDir . '/lib/public/Share/Exceptions/AlreadySharedException.php', 'OCP\\Share\\Exceptions\\GenericShareException' => $baseDir . '/lib/public/Share/Exceptions/GenericShareException.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index d3be8de3e78d0..ffde3b1bd7f64 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -994,7 +994,9 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Share\\Events\\ShareDeletedEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareDeletedEvent.php', 'OCP\\Share\\Events\\ShareDeletedFromSelfEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareDeletedFromSelfEvent.php', 'OCP\\Share\\Events\\ShareMovedEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareMovedEvent.php', + 'OCP\\Share\\Events\\ShareRestoredEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareRestoredEvent.php', 'OCP\\Share\\Events\\ShareTransferredEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareTransferredEvent.php', + 'OCP\\Share\\Events\\ShareUpdatedEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/ShareUpdatedEvent.php', 'OCP\\Share\\Events\\VerifyMountPointEvent' => __DIR__ . '/../../..' . '/lib/public/Share/Events/VerifyMountPointEvent.php', 'OCP\\Share\\Exceptions\\AlreadySharedException' => __DIR__ . '/../../..' . '/lib/public/Share/Exceptions/AlreadySharedException.php', 'OCP\\Share\\Exceptions\\GenericShareException' => __DIR__ . '/../../..' . '/lib/public/Share/Exceptions/GenericShareException.php', diff --git a/lib/private/Share20/Manager.php b/lib/private/Share20/Manager.php index a57b44858ff26..d17928591d2bb 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -69,6 +69,8 @@ use OCP\Share\Events\ShareDeletedEvent; use OCP\Share\Events\ShareDeletedFromSelfEvent; use OCP\Share\Events\ShareMovedEvent; +use OCP\Share\Events\ShareRestoredEvent; +use OCP\Share\Events\ShareUpdatedEvent; use OCP\Share\Exceptions\AlreadySharedException; use OCP\Share\Exceptions\GenericShareException; use OCP\Share\Exceptions\ShareNotFound; @@ -769,6 +771,8 @@ public function updateShare(IShare $share, bool $onlyValid = true): IShare { $share = $provider->update($share); } + $this->dispatcher->dispatchTyped(new ShareUpdatedEvent($share)); + if (in_array($share->getShareType(), [IShare::TYPE_USER, IShare::TYPE_GROUP, IShare::TYPE_CIRCLE, IShare::TYPE_ROOM, IShare::TYPE_DECK], true) && $originalShare->getAttributes()?->getAttribute('permissions', 'download') !== false && $share->getAttributes()?->getAttribute('permissions', 'download') === false) { @@ -898,6 +902,7 @@ public function restrictDownloadOfReshares(Node $node, array $restrictedShareIds $attributes->setAttribute('permissions', 'download', false); $reshare->setAttributes($attributes); $this->factory->getProviderForType($reshare->getShareType())->update($reshare); + $this->dispatcher->dispatchTyped(new ShareUpdatedEvent($reshare)); } } @@ -1243,7 +1248,11 @@ public function restoreShare(IShare $share, string $recipientId): IShare { [$providerId,] = $this->splitFullId($share->getFullId()); $provider = $this->factory->getProvider($providerId); - return $provider->restore($share, $recipientId); + $result = $provider->restore($share, $recipientId); + + $this->dispatcher->dispatchTyped(new ShareRestoredEvent($share)); + + return $result; } #[Override] @@ -1664,6 +1673,8 @@ public function checkPassword(IShare $share, ?string $password): bool { $share->setPasswordHash($newHash); $provider = $this->factory->getProviderForType($share->getShareType()); $provider->update($share); + + $this->dispatcher->dispatchTyped(new ShareUpdatedEvent($share)); } return true; diff --git a/lib/public/Share/Events/ShareRestoredEvent.php b/lib/public/Share/Events/ShareRestoredEvent.php new file mode 100644 index 0000000000000..fb851ef6130cb --- /dev/null +++ b/lib/public/Share/Events/ShareRestoredEvent.php @@ -0,0 +1,37 @@ +share; + } +} diff --git a/lib/public/Share/Events/ShareUpdatedEvent.php b/lib/public/Share/Events/ShareUpdatedEvent.php new file mode 100644 index 0000000000000..f7f0c91f1a60a --- /dev/null +++ b/lib/public/Share/Events/ShareUpdatedEvent.php @@ -0,0 +1,37 @@ +share; + } +} diff --git a/psalm-strict.xml b/psalm-strict.xml index 383d80f2d8b49..6990962ffe6fc 100644 --- a/psalm-strict.xml +++ b/psalm-strict.xml @@ -69,6 +69,8 @@ + + diff --git a/tests/lib/Share20/ManagerTest.php b/tests/lib/Share20/ManagerTest.php index efd385104e091..805a59425d733 100644 --- a/tests/lib/Share20/ManagerTest.php +++ b/tests/lib/Share20/ManagerTest.php @@ -56,6 +56,7 @@ use OCP\Share\Events\ShareCreatedEvent; use OCP\Share\Events\ShareDeletedEvent; use OCP\Share\Events\ShareDeletedFromSelfEvent; +use OCP\Share\Events\ShareUpdatedEvent; use OCP\Share\Exceptions\AlreadySharedException; use OCP\Share\Exceptions\GenericShareException; use OCP\Share\Exceptions\ShareNotFound; @@ -4795,7 +4796,7 @@ public function testUpdateShareWithoutAttributesOrTokenChangeDoesNotLog(): void ->with($share) ->willReturn($share); - $this->dispatcher->expects($this->never())->method('dispatchTyped'); + $this->dispatcher->expects($this->once())->method('dispatchTyped')->with(self::callback(static fn (Event $event): bool => $event instanceof ShareUpdatedEvent)); $manager->updateShare($share); } From 0780251bd26ace08178d4ce375f6b50efaff9d4c Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 23 Sep 2026 14:23:53 +0200 Subject: [PATCH 06/23] feat(OCP\Share\Events): Add setters for the share property Signed-off-by: provokateurin --- lib/private/Share20/Manager.php | 29 +++++++++++++------ .../Share/Events/ShareAcceptedEvent.php | 7 +++++ lib/public/Share/Events/ShareCreatedEvent.php | 16 ++++++---- lib/public/Share/Events/ShareDeletedEvent.php | 14 ++------- lib/public/Share/Events/ShareMovedEvent.php | 9 +++++- .../Share/Events/ShareRestoredEvent.php | 9 +++++- lib/public/Share/Events/ShareUpdatedEvent.php | 9 +++++- 7 files changed, 64 insertions(+), 29 deletions(-) diff --git a/lib/private/Share20/Manager.php b/lib/private/Share20/Manager.php index d17928591d2bb..ae765aea8becb 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -635,7 +635,9 @@ public function createShare(IShare $share): IShare { } // Post share event - $this->dispatchEvent(new ShareCreatedEvent($share), 'share created'); + $event = new ShareCreatedEvent($share); + $this->dispatchEvent($event, 'share created'); + $share = $event->getShare(); // Send email if needed if ($this->config->getSystemValueBool('sharing.enable_share_mail', true)) { @@ -771,7 +773,9 @@ public function updateShare(IShare $share, bool $onlyValid = true): IShare { $share = $provider->update($share); } - $this->dispatcher->dispatchTyped(new ShareUpdatedEvent($share)); + $event = new ShareUpdatedEvent($share); + $this->dispatcher->dispatchTyped($event); + $share = $event->getShare(); if (in_array($share->getShareType(), [IShare::TYPE_USER, IShare::TYPE_GROUP, IShare::TYPE_CIRCLE, IShare::TYPE_ROOM, IShare::TYPE_DECK], true) && $originalShare->getAttributes()?->getAttribute('permissions', 'download') !== false @@ -983,6 +987,7 @@ public function acceptShare(IShare $share, string $recipientId): IShare { $event = new ShareAcceptedEvent($share); $this->dispatchEvent($event, 'share accepted'); + $share = $event->getShare(); return $share; } @@ -1248,11 +1253,13 @@ public function restoreShare(IShare $share, string $recipientId): IShare { [$providerId,] = $this->splitFullId($share->getFullId()); $provider = $this->factory->getProvider($providerId); - $result = $provider->restore($share, $recipientId); + $share = $provider->restore($share, $recipientId); - $this->dispatcher->dispatchTyped(new ShareRestoredEvent($share)); + $event = new ShareRestoredEvent($share); + $this->dispatcher->dispatchTyped($event); + $share = $event->getShare(); - return $result; + return $share; } #[Override] @@ -1283,11 +1290,13 @@ public function moveShare(IShare $share, string $recipientId): IShare { [$providerId,] = $this->splitFullId($share->getFullId()); $provider = $this->factory->getProvider($providerId); - $result = $provider->move($share, $recipientId); + $share = $provider->move($share, $recipientId); - $this->dispatchEvent(new ShareMovedEvent($share, $recipient), 'share moved'); + $event = new ShareMovedEvent($share, $recipient); + $this->dispatchEvent($event, 'share moved'); + $share = $event->getShare(); - return $result; + return $share; } #[Override] @@ -1674,7 +1683,9 @@ public function checkPassword(IShare $share, ?string $password): bool { $provider = $this->factory->getProviderForType($share->getShareType()); $provider->update($share); - $this->dispatcher->dispatchTyped(new ShareUpdatedEvent($share)); + $event = new ShareUpdatedEvent($share); + $this->dispatcher->dispatchTyped($event); + $share = $event->getShare(); } return true; diff --git a/lib/public/Share/Events/ShareAcceptedEvent.php b/lib/public/Share/Events/ShareAcceptedEvent.php index aa5f5beb2cc9a..255dc6a19503d 100644 --- a/lib/public/Share/Events/ShareAcceptedEvent.php +++ b/lib/public/Share/Events/ShareAcceptedEvent.php @@ -31,4 +31,11 @@ public function __construct( public function getShare(): IShare { return $this->share; } + + /** + * @since 35.0.0 + */ + public function setShare(IShare $share): void { + $this->share = $share; + } } diff --git a/lib/public/Share/Events/ShareCreatedEvent.php b/lib/public/Share/Events/ShareCreatedEvent.php index 3681f68e0a0e7..512c339aa87bb 100644 --- a/lib/public/Share/Events/ShareCreatedEvent.php +++ b/lib/public/Share/Events/ShareCreatedEvent.php @@ -16,16 +16,13 @@ * @since 18.0.0 */ class ShareCreatedEvent extends Event { - /** @var IShare */ - private $share; - /** * @since 18.0.0 */ - public function __construct(IShare $share) { + public function __construct( + private IShare $share, + ) { parent::__construct(); - - $this->share = $share; } /** @@ -34,4 +31,11 @@ public function __construct(IShare $share) { public function getShare(): IShare { return $this->share; } + + /** + * @since 35.0.0 + */ + public function setShare(IShare $share): void { + $this->share = $share; + } } diff --git a/lib/public/Share/Events/ShareDeletedEvent.php b/lib/public/Share/Events/ShareDeletedEvent.php index 295e483cb84f5..a83e6c061aef2 100644 --- a/lib/public/Share/Events/ShareDeletedEvent.php +++ b/lib/public/Share/Events/ShareDeletedEvent.php @@ -16,24 +16,16 @@ * @since 21.0.0 */ class ShareDeletedEvent extends Event { - /** @var IShare */ - private $share; - /** - * - * @param IShare $share - * @param IShare[] $children - * * @since 21.0.0 */ - public function __construct(IShare $share) { + public function __construct( + private IShare $share, + ) { parent::__construct(); - - $this->share = $share; } /** - * @return IShare * @since 21.0.0 */ public function getShare(): IShare { diff --git a/lib/public/Share/Events/ShareMovedEvent.php b/lib/public/Share/Events/ShareMovedEvent.php index a29bc5a9f41d1..5195acc5b4c71 100644 --- a/lib/public/Share/Events/ShareMovedEvent.php +++ b/lib/public/Share/Events/ShareMovedEvent.php @@ -21,7 +21,7 @@ class ShareMovedEvent extends Event { * @since 33.0.0 */ public function __construct( - private readonly IShare $share, + private IShare $share, private readonly IUser $user, ) { parent::__construct(); @@ -40,4 +40,11 @@ public function getShare(): IShare { public function getUser(): IUser { return $this->user; } + + /** + * @since 35.0.0 + */ + public function setShare(IShare $share): void { + $this->share = $share; + } } diff --git a/lib/public/Share/Events/ShareRestoredEvent.php b/lib/public/Share/Events/ShareRestoredEvent.php index fb851ef6130cb..2452a79202688 100644 --- a/lib/public/Share/Events/ShareRestoredEvent.php +++ b/lib/public/Share/Events/ShareRestoredEvent.php @@ -24,7 +24,7 @@ final class ShareRestoredEvent extends Event { * @since 35.0.0 */ public function __construct( - public readonly IShare $share, + public IShare $share, ) { } @@ -34,4 +34,11 @@ public function __construct( public function getShare(): IShare { return $this->share; } + + /** + * @since 35.0.0 + */ + public function setShare(IShare $share): void { + $this->share = $share; + } } diff --git a/lib/public/Share/Events/ShareUpdatedEvent.php b/lib/public/Share/Events/ShareUpdatedEvent.php index f7f0c91f1a60a..163b69da5be82 100644 --- a/lib/public/Share/Events/ShareUpdatedEvent.php +++ b/lib/public/Share/Events/ShareUpdatedEvent.php @@ -24,7 +24,7 @@ final class ShareUpdatedEvent extends Event { * @since 35.0.0 */ public function __construct( - public readonly IShare $share, + public IShare $share, ) { } @@ -34,4 +34,11 @@ public function __construct( public function getShare(): IShare { return $this->share; } + + /** + * @since 35.0.0 + */ + public function setShare(IShare $share): void { + $this->share = $share; + } } From 2f4f3b844499ec952ba657ab9a398f2187cce896 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 2 Sep 2026 11:08:09 +0200 Subject: [PATCH 07/23] feat(SharingManager): Dispatch SharesUpdatedEvent and SharesDeletedEvent Signed-off-by: provokateurin --- lib/composer/composer/autoload_classmap.php | 2 + lib/composer/composer/autoload_static.php | 2 + lib/private/Sharing/SharingBackend.php | 16 +++--- lib/private/Sharing/SharingManager.php | 13 ++++- .../Sharing/Event/SharesDeletedEvent.php | 31 ++++++++++ .../Sharing/Event/SharesUpdatedEvent.php | 56 +++++++++++++++++++ lib/unstable/Sharing/ISharingBackend.php | 3 +- 7 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 lib/unstable/Sharing/Event/SharesDeletedEvent.php create mode 100644 lib/unstable/Sharing/Event/SharesUpdatedEvent.php diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index e1a87f95893d5..26682dcff3549 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -46,6 +46,8 @@ 'NCU\\Security\\Signature\\ISignedRequest' => $baseDir . '/lib/unstable/Security/Signature/ISignedRequest.php', 'NCU\\Security\\Signature\\Model\\Signatory' => $baseDir . '/lib/unstable/Security/Signature/Model/Signatory.php', 'NCU\\Sharing\\Event\\SharesDefaultSetEvent' => $baseDir . '/lib/unstable/Sharing/Event/SharesDefaultSetEvent.php', + 'NCU\\Sharing\\Event\\SharesDeletedEvent' => $baseDir . '/lib/unstable/Sharing/Event/SharesDeletedEvent.php', + 'NCU\\Sharing\\Event\\SharesUpdatedEvent' => $baseDir . '/lib/unstable/Sharing/Event/SharesUpdatedEvent.php', 'NCU\\Sharing\\Exception\\AShareException' => $baseDir . '/lib/unstable/Sharing/Exception/AShareException.php', 'NCU\\Sharing\\Exception\\ShareInvalidException' => $baseDir . '/lib/unstable/Sharing/Exception/ShareInvalidException.php', 'NCU\\Sharing\\Exception\\ShareNotFoundException' => $baseDir . '/lib/unstable/Sharing/Exception/ShareNotFoundException.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index ffde3b1bd7f64..003a89df24699 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -87,6 +87,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'NCU\\Security\\Signature\\ISignedRequest' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/ISignedRequest.php', 'NCU\\Security\\Signature\\Model\\Signatory' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Model/Signatory.php', 'NCU\\Sharing\\Event\\SharesDefaultSetEvent' => __DIR__ . '/../../..' . '/lib/unstable/Sharing/Event/SharesDefaultSetEvent.php', + 'NCU\\Sharing\\Event\\SharesDeletedEvent' => __DIR__ . '/../../..' . '/lib/unstable/Sharing/Event/SharesDeletedEvent.php', + 'NCU\\Sharing\\Event\\SharesUpdatedEvent' => __DIR__ . '/../../..' . '/lib/unstable/Sharing/Event/SharesUpdatedEvent.php', 'NCU\\Sharing\\Exception\\AShareException' => __DIR__ . '/../../..' . '/lib/unstable/Sharing/Exception/AShareException.php', 'NCU\\Sharing\\Exception\\ShareInvalidException' => __DIR__ . '/../../..' . '/lib/unstable/Sharing/Exception/ShareInvalidException.php', 'NCU\\Sharing\\Exception\\ShareNotFoundException' => __DIR__ . '/../../..' . '/lib/unstable/Sharing/Exception/ShareNotFoundException.php', diff --git a/lib/private/Sharing/SharingBackend.php b/lib/private/Sharing/SharingBackend.php index 84f793d404573..a14ad36a5105c 100644 --- a/lib/private/Sharing/SharingBackend.php +++ b/lib/private/Sharing/SharingBackend.php @@ -550,7 +550,7 @@ public function deleteShare(string $id): void { public function getShare(ShareAccessContext $accessContext, string $id): Share { $this->assertInTransaction(); - $shares = $this->list($accessContext, $id, null, null, null, null, null, null); + $shares = $this->list($accessContext, [$id], null, null, null, null, null, null); if (count($shares) !== 1) { throw new ShareNotFoundException(); } @@ -561,6 +561,7 @@ public function getShare(ShareAccessContext $accessContext, string $id): Share { #[\Override] public function getShares( ShareAccessContext $accessContext, + ?array $filterShareIDs, ?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?ShareState $filterState, @@ -570,7 +571,7 @@ public function getShares( ): array { $this->assertInTransaction(); - return $this->list($accessContext, null, $filterSourceTypeClass, $filterSourceTypeValue, $filterState, $filterUserStatus, $lastShareID, $limit); + return $this->list($accessContext, $filterShareIDs, $filterSourceTypeClass, $filterSourceTypeValue, $filterState, $filterUserStatus, $lastShareID, $limit); } #[\Override] @@ -623,13 +624,14 @@ private function hideDisabledUserShares(): bool { // TODO: Split up the method /** + * @param ?non-empty-list $filterShareIDs * @param ?class-string $filterSourceTypeClass * @param ?non-empty-string $filterSourceTypeValue * @return list */ private function list( ShareAccessContext $accessContext, - ?string $filterShareID, + ?array $filterShareIDs, ?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?ShareState $filterState, @@ -657,7 +659,7 @@ private function list( if ($accessContext->currentUser instanceof IUser) { $qb = $this->connection->getQueryBuilder(); // if we're filtering by source or id, we need to also check for non-owned shares - if ($filterSourceTypeValue === null && $filterShareID === null) { + if ($filterSourceTypeValue === null && $filterShareIDs === null) { $qb->where($qb->expr()->eq('s.owner_user_id', $qb->createNamedParameter($accessContext->currentUser->getUID()))); } @@ -699,7 +701,7 @@ private function list( $queries[] = $qb; } - if ($filterShareID !== null && $accessContext->secret !== null) { + if ($filterShareIDs !== null && $accessContext->secret !== null) { $qb = $this->connection->getQueryBuilder(); $qb->innerJoin( 's', 'sharing_share_recipients', 'sr', $qb->expr()->andX( @@ -728,8 +730,8 @@ private function list( ->from('sharing_share', 's') ->orderBy('s.id', 'ASC'); - if ($filterShareID !== null) { - $qb->andWhere($qb->expr()->eq('s.id', $qb->createNamedParameter($filterShareID))); + if ($filterShareIDs !== null) { + $qb->andWhere($qb->expr()->in('s.id', $qb->createNamedParameter($filterShareIDs, IQueryBuilder::PARAM_STR_ARRAY))); } if ($filterSourceType !== null && $filterSourceTypeClass !== null) { diff --git a/lib/private/Sharing/SharingManager.php b/lib/private/Sharing/SharingManager.php index e9bba995337b7..97fd550dc6fea 100644 --- a/lib/private/Sharing/SharingManager.php +++ b/lib/private/Sharing/SharingManager.php @@ -11,6 +11,8 @@ use Exception; use NCU\Sharing\Event\SharesDefaultSetEvent; +use NCU\Sharing\Event\SharesDeletedEvent; +use NCU\Sharing\Event\SharesUpdatedEvent; use NCU\Sharing\Exception\ShareInvalidException; use NCU\Sharing\Exception\ShareOperationForbiddenException; use NCU\Sharing\ISharingBackend; @@ -190,7 +192,10 @@ public function onOwnerDeleted(ShareAccessContext $accessContext, ShareUser $own // No need to update the last updated timestamp, because the share will be deleted anyway. - $this->backend->onOwnerDeleted($owner); + $ids = $this->backend->onOwnerDeleted($owner); + if ($ids !== []) { + $this->eventDispatcher->dispatchTyped(new SharesDeletedEvent($ids)); + } } #[\Override] @@ -837,6 +842,8 @@ public function deleteShare(ShareAccessContext $accessContext, Share $share): vo 'shareId' => $share->id, 'owner' => $share->owner->userId, ], 'unified_sharing.share.deleted'); + + $this->eventDispatcher->dispatchTyped(new SharesDeletedEvent([$share->id])); } /** @@ -861,7 +868,7 @@ public function getShares( ?string $lastShareID, ?int $limit, ): array { - return $this->backend->getShares($accessContext, $filterSourceTypeClass, $filterSourceTypeValue, $filterState, $filterUserStatus, $lastShareID, $limit); + return $this->backend->getShares($accessContext, null, $filterSourceTypeClass, $filterSourceTypeValue, $filterState, $filterUserStatus, $lastShareID, $limit); } #[\Override] @@ -1070,6 +1077,8 @@ private function processShareUpdates(array $shares): array { } } + $this->eventDispatcher->dispatchTyped(new SharesUpdatedEvent(array_map(static fn (Share $share): string => $share->id, $shares))); + return $shares; } diff --git a/lib/unstable/Sharing/Event/SharesDeletedEvent.php b/lib/unstable/Sharing/Event/SharesDeletedEvent.php new file mode 100644 index 0000000000000..174bf97619c58 --- /dev/null +++ b/lib/unstable/Sharing/Event/SharesDeletedEvent.php @@ -0,0 +1,31 @@ + $shareIds + * @experimental 35.0.0 + */ + public function __construct( + public readonly array $shareIds, + ) { + parent::__construct(); + } +} diff --git a/lib/unstable/Sharing/Event/SharesUpdatedEvent.php b/lib/unstable/Sharing/Event/SharesUpdatedEvent.php new file mode 100644 index 0000000000000..ba64779e58116 --- /dev/null +++ b/lib/unstable/Sharing/Event/SharesUpdatedEvent.php @@ -0,0 +1,56 @@ + $shares */ + private ?array $shares = null; + + /** + * @param non-empty-list $shareIds + * @experimental 35.0.0 + */ + public function __construct( + public readonly array $shareIds, + ) { + parent::__construct(); + } + + /** + * @return non-empty-list + * @experimental 35.0.0 + */ + public function getShares(): array { + if ($this->shares !== null) { + return $this->shares; + } + + $shares = Server::get(ISharingBackend::class)->getShares(new ShareAccessContext(overrideChecks: true), $this->shareIds, null, null, null, null, null, null); + if ($shares === [] || count($shares) !== count($this->shareIds)) { + throw new ShareNotFoundException(); + } + + return $this->shares = $shares; + } +} diff --git a/lib/unstable/Sharing/ISharingBackend.php b/lib/unstable/Sharing/ISharingBackend.php index 141c8a91a1979..2d2a86feb2094 100644 --- a/lib/unstable/Sharing/ISharingBackend.php +++ b/lib/unstable/Sharing/ISharingBackend.php @@ -189,6 +189,7 @@ public function getShare(ShareAccessContext $accessContext, string $id): Share; /** * Get multiple shares. * + * @param ?non-empty-list $filterShareIDs * @param ?class-string $filterSourceTypeClass * @param ?non-empty-string $filterSourceTypeValue * @param ?non-empty-string $lastShareID @@ -196,7 +197,7 @@ public function getShare(ShareAccessContext $accessContext, string $id): Share; * @return list * @experimental 35.0.0 */ - public function getShares(ShareAccessContext $accessContext, ?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?ShareState $filterState, ?ShareUserStatus $filterUserStatus, ?string $lastShareID, ?int $limit): array; + public function getShares(ShareAccessContext $accessContext, ?array $filterShareIDs, ?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?ShareState $filterState, ?ShareUserStatus $filterUserStatus, ?string $lastShareID, ?int $limit): array; /** * Get the owner of a share. From 891a8be7408df5aafad0ce329d7fc2aeb53a2bf3 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 23 Sep 2026 14:35:35 +0200 Subject: [PATCH 08/23] feat(DefaultShareProvider): Allow fetching shares of TYPE_USERGROUP Signed-off-by: provokateurin --- apps/sharebymail/lib/ShareByMailProvider.php | 6 +++++- lib/private/Share20/DefaultShareProvider.php | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/sharebymail/lib/ShareByMailProvider.php b/apps/sharebymail/lib/ShareByMailProvider.php index 6f51cf09b7c85..402fa17d39986 100644 --- a/apps/sharebymail/lib/ShareByMailProvider.php +++ b/apps/sharebymail/lib/ShareByMailProvider.php @@ -1253,7 +1253,11 @@ public function getAccessList($nodes, $currentAccess): array { } #[\Override] - public function getAllShares(): iterable { + public function getAllShares(bool $withUserGroup = false): iterable { + if ($withUserGroup) { + throw new RuntimeException('Unintended usage.'); + } + $qb = $this->dbConnection->getQueryBuilder(); $qb->select('*') diff --git a/lib/private/Share20/DefaultShareProvider.php b/lib/private/Share20/DefaultShareProvider.php index cd9e65cd757f5..d7b2d5cf042f6 100644 --- a/lib/private/Share20/DefaultShareProvider.php +++ b/lib/private/Share20/DefaultShareProvider.php @@ -1137,7 +1137,11 @@ private function createShare($data): IShare { $shareTime->setTimestamp((int)$data['stime']); $share->setShareTime($shareTime); - if ($share->getShareType() === IShare::TYPE_USER) { + if (($parent = $data['parent']) !== null) { + $share->setParent((int)$parent); + } + + if ($share->getShareType() === IShare::TYPE_USER || $share->getShareType() === IShare::TYPE_USERGROUP) { $share->setSharedWith($data['share_with']); $share->setSharedWithDisplayNameCallback(fn (IShare $share) => $this->userManager->getDisplayName($share->getSharedWith())); } elseif ($share->getShareType() === IShare::TYPE_GROUP) { @@ -1760,12 +1764,17 @@ private function sendNote(array $recipients, IShare $share) { } #[\Override] - public function getAllShares(): iterable { + public function getAllShares(bool $withUserGroup = false): iterable { + $shareTypes = [IShare::TYPE_USER, IShare::TYPE_GROUP, IShare::TYPE_LINK]; + if ($withUserGroup) { + $shareTypes[] = IShare::TYPE_USERGROUP; + } + $qb = $this->dbConn->getQueryBuilder(); $qb->select('*') ->from('share') - ->where($qb->expr()->in('share_type', $qb->createNamedParameter([IShare::TYPE_USER, IShare::TYPE_GROUP, IShare::TYPE_LINK], IQueryBuilder::PARAM_INT_ARRAY))); + ->where($qb->expr()->in('share_type', $qb->createNamedParameter($shareTypes, IQueryBuilder::PARAM_INT_ARRAY))); $cursor = $qb->executeQuery(); while ($data = $cursor->fetchAssociative()) { From b88c5b00b5285fb6db9d30dbc9c28aaa31c6efa9 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Thu, 10 Sep 2026 11:06:35 +0200 Subject: [PATCH 09/23] feat(ITeamManager): Allow calling getMembersOfTeam() without a user Signed-off-by: provokateurin --- lib/private/Teams/TeamManager.php | 12 ++++++++---- lib/public/Teams/ITeamManager.php | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/private/Teams/TeamManager.php b/lib/private/Teams/TeamManager.php index d17aefe36223b..c2992350e6760 100644 --- a/lib/private/Teams/TeamManager.php +++ b/lib/private/Teams/TeamManager.php @@ -134,14 +134,18 @@ public function getTeamsForResource(string $providerId, string $resourceId, stri return array_map($this->circleToTeam(...), $this->getTeams($provider->getTeamsForResource($resourceId), $userId)); } - private function getTeamInternal(string $teamId, string $userId, ?CircleProbe $probe = null): ?Circle { + private function getTeamInternal(string $teamId, ?string $userId, ?CircleProbe $probe = null): ?Circle { if (!$this->hasTeamSupport()) { return null; } + if ($userId !== null) { + $this->circlesManager->startSession($this->circlesManager->getLocalFederatedUser($userId)); + } else { + $this->circlesManager->startSuperSession(); + } + try { - $federatedUser = $this->circlesManager->getFederatedUser($userId, Member::TYPE_USER); - $this->circlesManager->startSession($federatedUser); return $this->circlesManager->getCircle($teamId, $probe); } catch (CircleNotFoundException) { return null; @@ -154,7 +158,7 @@ private function getTeamInternal(string $teamId, string $userId, ?CircleProbe $p * @return array userId => displayName */ #[\Override] - public function getMembersOfTeam(string $teamId, string $userId): array { + public function getMembersOfTeam(string $teamId, ?string $userId): array { $team = $this->getTeamInternal($teamId, $userId); if ($team === null) { return []; diff --git a/lib/public/Teams/ITeamManager.php b/lib/public/Teams/ITeamManager.php index f6da931f85511..09760a5b1ea34 100644 --- a/lib/public/Teams/ITeamManager.php +++ b/lib/public/Teams/ITeamManager.php @@ -70,12 +70,12 @@ public function getTeamsForUser(string $userId): array; * Returns a mapping of user ID to display name for all members of a given team. * * @param string $teamId ID of the team whose members are being queried - * @param string $userId ID of the user from whose point of view the members are being queried + * @param ?string $userId ID of the user from whose point of view the members are being queried * * @return array userId => displayName * @since 34.0.0 */ - public function getMembersOfTeam(string $teamId, string $userId): array; + public function getMembersOfTeam(string $teamId, ?string $userId): array; /** * Returns whether the Teams backend is available From ad52e6434909d4edb38c1a2ef731e5c699a22d13 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Thu, 10 Sep 2026 11:11:09 +0200 Subject: [PATCH 10/23] feat(IShareRecipientType): Add getUsers() Signed-off-by: provokateurin --- .../Recipient/EmailShareRecipientType.php | 5 +++++ .../Recipient/GroupShareRecipientType.php | 5 +++++ .../Recipient/TeamShareRecipientType.php | 7 +++++++ .../Recipient/TokenShareRecipientType.php | 5 +++++ .../Recipient/UserShareRecipientType.php | 8 ++++++++ .../Sharing/Recipient/IShareRecipientType.php | 8 ++++++++ .../Recipient/GroupShareRecipientTypeTest.php | 8 ++++++-- .../Recipient/TeamShareRecipientTypeTest.php | 18 +++++++++++++----- .../Recipient/UserShareRecipientTypeTest.php | 10 ++++++++-- tests/lib/Sharing/TestShareRecipientType1.php | 5 +++++ .../TestShareRecipientTypeArguments.php | 5 +++++ .../TestShareRecipientTypePublicSecret.php | 5 +++++ 12 files changed, 80 insertions(+), 9 deletions(-) diff --git a/core/Sharing/Recipient/EmailShareRecipientType.php b/core/Sharing/Recipient/EmailShareRecipientType.php index a42d43a8c8944..f0157c10f7110 100644 --- a/core/Sharing/Recipient/EmailShareRecipientType.php +++ b/core/Sharing/Recipient/EmailShareRecipientType.php @@ -44,6 +44,11 @@ public function getRecipients(?IUser $currentUser, mixed $arguments): array { return []; } + #[\Override] + public function getUsers(string $recipient): array { + return []; + } + #[\Override] public function getRecipientDisplayName(string $recipient, ?string $instance): ?string { if ($instance !== null) { diff --git a/core/Sharing/Recipient/GroupShareRecipientType.php b/core/Sharing/Recipient/GroupShareRecipientType.php index 2519947c46193..c1278eacff77f 100644 --- a/core/Sharing/Recipient/GroupShareRecipientType.php +++ b/core/Sharing/Recipient/GroupShareRecipientType.php @@ -63,6 +63,11 @@ public function getRecipients(?IUser $currentUser, mixed $arguments): array { return $this->groupManager->getUserGroupIds($currentUser); } + #[\Override] + public function getUsers(string $recipient): array { + return array_values($this->groupManager->get($recipient)?->getUsers() ?? []); + } + #[\Override] public function getRecipientDisplayName(string $recipient, ?string $instance): ?string { if ($instance !== null) { diff --git a/core/Sharing/Recipient/TeamShareRecipientType.php b/core/Sharing/Recipient/TeamShareRecipientType.php index 36a6537781558..ec773cf411fba 100644 --- a/core/Sharing/Recipient/TeamShareRecipientType.php +++ b/core/Sharing/Recipient/TeamShareRecipientType.php @@ -25,6 +25,7 @@ use OCP\Interaction\InteractionReceiver; use OCP\Interaction\Receivers\CircleReceiver; use OCP\IUser; +use OCP\IUserManager; use OCP\L10N\IFactory; use OCP\Server; use OCP\Share\IShare; @@ -41,6 +42,7 @@ public function __construct( IEventDispatcher $eventDispatcher, private readonly IDBConnection $dbConnection, private readonly ISharingManager $manager, + private readonly IUserManager $userManager, ) { } @@ -68,6 +70,11 @@ public function getRecipients(?IUser $currentUser, mixed $arguments): array { return array_map(static fn (Team $team): string => $team->getId(), $this->getTeamManager()->getTeamsForUser($currentUser->getUID())); } + #[\Override] + public function getUsers(string $recipient): array { + return array_map($this->userManager->getExistingUser(...), array_keys($this->getTeamManager()->getMembersOfTeam($recipient, null))); + } + #[\Override] public function getRecipientDisplayName(string $recipient, ?string $instance): ?string { if ($instance !== null) { diff --git a/core/Sharing/Recipient/TokenShareRecipientType.php b/core/Sharing/Recipient/TokenShareRecipientType.php index 115a45e89cdce..be92071bdbe36 100644 --- a/core/Sharing/Recipient/TokenShareRecipientType.php +++ b/core/Sharing/Recipient/TokenShareRecipientType.php @@ -42,6 +42,11 @@ public function getRecipients(?IUser $currentUser, mixed $arguments): array { return []; } + #[\Override] + public function getUsers(string $recipient): array { + return []; + } + #[\Override] public function getRecipientDisplayName(string $recipient, ?string $instance): ?string { return null; diff --git a/core/Sharing/Recipient/UserShareRecipientType.php b/core/Sharing/Recipient/UserShareRecipientType.php index 113fb16a8ca8c..00649939237bd 100644 --- a/core/Sharing/Recipient/UserShareRecipientType.php +++ b/core/Sharing/Recipient/UserShareRecipientType.php @@ -62,6 +62,14 @@ public function getRecipients(?IUser $currentUser, mixed $arguments): array { return [$currentUser->getUID()]; } + /** + * @return list + */ + #[\Override] + public function getUsers(string $recipient): array { + return [$this->userManager->getExistingUser($recipient)]; + } + #[\Override] public function getRecipientDisplayName(string $recipient, ?string $instance): ?string { if ($instance !== null) { diff --git a/lib/unstable/Sharing/Recipient/IShareRecipientType.php b/lib/unstable/Sharing/Recipient/IShareRecipientType.php index fc3d2b6a4ed19..b91229e90538b 100644 --- a/lib/unstable/Sharing/Recipient/IShareRecipientType.php +++ b/lib/unstable/Sharing/Recipient/IShareRecipientType.php @@ -47,6 +47,14 @@ public function validateRecipient(string $recipient): bool; */ public function getRecipients(?IUser $currentUser, mixed $arguments): array; + /** + * Get user ids for a recipient value. + * + * @return list + * @experimental 35.0.0 + */ + public function getUsers(string $recipient): array; + /** * @param non-empty-string $recipient * @return ?non-empty-string diff --git a/tests/Core/Sharing/Recipient/GroupShareRecipientTypeTest.php b/tests/Core/Sharing/Recipient/GroupShareRecipientTypeTest.php index 7e932a79e61de..56cb1ee01c4fb 100644 --- a/tests/Core/Sharing/Recipient/GroupShareRecipientTypeTest.php +++ b/tests/Core/Sharing/Recipient/GroupShareRecipientTypeTest.php @@ -104,8 +104,12 @@ public function testValidateRecipient(): void { $this->assertFalse($this->recipientType->validateRecipient('invalid')); } - public function testGetRecipientValues(): void { - $this->assertEquals(['group1'], $this->recipientType->getRecipients($this->user1, null)); + public function testGetRecipients(): void { + $this->assertEquals([$this->group1->getGID()], $this->recipientType->getRecipients($this->user1, null)); + } + + public function testGetUsers(): void { + $this->assertEquals([$this->user1], $this->recipientType->getUsers($this->group1->getGID())); } public function testGetRecipientDisplayName(): void { diff --git a/tests/Core/Sharing/Recipient/TeamShareRecipientTypeTest.php b/tests/Core/Sharing/Recipient/TeamShareRecipientTypeTest.php index 61ea50089b892..3104e00fd8aa6 100644 --- a/tests/Core/Sharing/Recipient/TeamShareRecipientTypeTest.php +++ b/tests/Core/Sharing/Recipient/TeamShareRecipientTypeTest.php @@ -14,6 +14,8 @@ use NCU\Sharing\Recipient\ShareRecipient; use NCU\Sharing\ShareAccessContext; use OC\Core\Sharing\Recipient\TeamShareRecipientType; +use OC\Sharing\SharingManager; +use OC\User\LazyUser; use OCA\Circles\CirclesManager; use OCA\Circles\Exceptions\CircleNotFoundException; use OCA\Circles\Service\CircleService; @@ -98,7 +100,7 @@ public function setUp(): void { $this->team2 = $this->createTeam($teamManager, 'team2'); $this->team3 = $this->createTeam($teamManager, 'team3'); - $this->recipientType = new TeamShareRecipientType(Server::get(IEventDispatcher::class), $this->dbConnection, $this->manager); + $this->recipientType = new TeamShareRecipientType(Server::get(IEventDispatcher::class), $this->dbConnection, $this->manager, Server::get(IUserManager::class)); } #[\Override] @@ -125,14 +127,20 @@ public function testValidateRecipient(): void { $this->assertFalse($this->recipientType->validateRecipient('invalid')); } - public function testGetRecipientValues(): void { + public function testGetRecipients(): void { $this->assertContains($this->team1->getId(), $this->recipientType->getRecipients($this->user1, null)); } + /** @psalm-suppress UnevaluatedCode Test is skipped */ + public function testGetUsers(): void { + $this->markTestSkipped('This test is broken if run together with other test suites because circles 🤷‍♀️'); + $this->assertEquals([$this->user1], array_map(fn (LazyUser $user): IUser => $this->invokePrivate($user, 'getUser'), $this->recipientType->getUsers($this->team1->getId()))); + } + /** @psalm-suppress UnevaluatedCode Test is skipped */ public function testGetRecipientDisplayName(): void { $this->markTestSkipped('This test is broken if run together with other test suites because circles 🤷‍♀️'); - $this->assertEquals('Team 1', $this->recipientType->getRecipientDisplayName($this->team1->getId())); + $this->assertEquals('Team 1', $this->recipientType->getRecipientDisplayName($this->team1->getId(), null)); } public function testSearchRecipients(): void { @@ -177,8 +185,8 @@ public function testDelete(): void { $this->dbConnection->beginTransaction(); $share = $this->manager->getShare($accessContext, $share->id); - $this->assertGreaterThanOrEqual($before, $share->lastUpdated->getTimestamp()); - $this->assertLessThanOrEqual($after, $share->lastUpdated->getTimestamp()); + $this->assertGreaterThanOrEqual(SharingManager::timeToMs($before), SharingManager::timeToMs($share->lastUpdated)); + $this->assertLessThanOrEqual(SharingManager::timeToMs($after), SharingManager::timeToMs($share->lastUpdated)); $this->assertEquals([], $share->recipients); $this->manager->deleteShare($accessContext, $share); diff --git a/tests/Core/Sharing/Recipient/UserShareRecipientTypeTest.php b/tests/Core/Sharing/Recipient/UserShareRecipientTypeTest.php index 20978861bb5d6..f58f3d229c79b 100644 --- a/tests/Core/Sharing/Recipient/UserShareRecipientTypeTest.php +++ b/tests/Core/Sharing/Recipient/UserShareRecipientTypeTest.php @@ -17,6 +17,7 @@ use OC\Core\Sharing\Recipient\UserShareRecipientType; use OC\Sharing\SharingManager; use OC\User\Database; +use OC\User\LazyUser; use OCA\DAV\CardDAV\CardDavBackend; use OCA\DAV\CardDAV\ContactsManager; use OCP\Contacts\IManager; @@ -95,8 +96,13 @@ public function testValidateRecipient(): void { $this->assertFalse($this->recipientType->validateRecipient('invalid')); } - public function testGetRecipientValues(): void { - $this->assertEquals(['user1'], $this->recipientType->getRecipients($this->user1, null)); + public function testGetRecipients(): void { + $this->assertEquals([$this->user1->getUID()], $this->recipientType->getRecipients($this->user1, null)); + } + + public function testGetUsers(): void { + /** @psalm-suppress ArgumentTypeCoercion, MixedReturnStatement */ + $this->assertEquals([$this->user1], array_map(fn (LazyUser $user): IUser => $this->invokePrivate($user, 'getUser'), $this->recipientType->getUsers($this->user1->getUID()))); } public function testGetRecipientDisplayName(): void { diff --git a/tests/lib/Sharing/TestShareRecipientType1.php b/tests/lib/Sharing/TestShareRecipientType1.php index fcc2f1ac2df75..7a8a3a57446f8 100644 --- a/tests/lib/Sharing/TestShareRecipientType1.php +++ b/tests/lib/Sharing/TestShareRecipientType1.php @@ -54,6 +54,11 @@ public function getRecipients(?IUser $currentUser, mixed $arguments): array { return $this->recipientValues[$currentUser->getUID()] ?? []; } + #[\Override] + public function getUsers(string $recipient): array { + return []; + } + #[\Override] public function getRecipientDisplayName(string $recipient, ?string $instance): ?string { if ($instance !== null) { diff --git a/tests/lib/Sharing/TestShareRecipientTypeArguments.php b/tests/lib/Sharing/TestShareRecipientTypeArguments.php index 211a6bfb87620..4ff2c8982109f 100644 --- a/tests/lib/Sharing/TestShareRecipientTypeArguments.php +++ b/tests/lib/Sharing/TestShareRecipientTypeArguments.php @@ -38,6 +38,11 @@ public function getRecipients(?IUser $currentUser, mixed $arguments): array { return []; } + #[\Override] + public function getUsers(string $recipient): array { + return []; + } + #[\Override] public function getRecipientDisplayName(string $recipient, ?string $instance): ?string { return null; diff --git a/tests/lib/Sharing/TestShareRecipientTypePublicSecret.php b/tests/lib/Sharing/TestShareRecipientTypePublicSecret.php index f9fb118bacf01..d7966f1f0439d 100644 --- a/tests/lib/Sharing/TestShareRecipientTypePublicSecret.php +++ b/tests/lib/Sharing/TestShareRecipientTypePublicSecret.php @@ -52,6 +52,11 @@ public function getRecipients(?IUser $currentUser, mixed $arguments): array { return $this->recipientValues[$currentUser->getUID()] ?? []; } + #[\Override] + public function getUsers(string $recipient): array { + return []; + } + #[\Override] public function getRecipientDisplayName(string $recipient, ?string $instance): ?string { return $this->validRecipients[$recipient]; From 4366fdbd21006a0ebebf255f54b99041365659cc Mon Sep 17 00:00:00 2001 From: provokateurin Date: Thu, 10 Sep 2026 13:41:04 +0200 Subject: [PATCH 11/23] feat(Sharing): Add node source target management Signed-off-by: provokateurin --- apps/files/appinfo/info.xml | 2 +- .../composer/composer/autoload_classmap.php | 3 + .../composer/composer/autoload_static.php | 3 + apps/files/lib/AppInfo/Application.php | 3 + .../Version35000Date20260910082613.php | 38 +++++ .../lib/Sharing/SharesUpdatedListener.php | 75 ++++++++++ .../lib/Sharing/SourceNodeTargetManager.php | 141 ++++++++++++++++++ .../Sharing/SharesUpdatedListenerTest.php | 119 +++++++++++++++ .../Sharing/SourceNodeTargetManagerTest.php | 104 +++++++++++++ build/rector-strict.php | 1 + psalm-strict.xml | 1 + 11 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 apps/files/lib/Migration/Version35000Date20260910082613.php create mode 100644 apps/files/lib/Sharing/SharesUpdatedListener.php create mode 100644 apps/files/lib/Sharing/SourceNodeTargetManager.php create mode 100644 apps/files/tests/Sharing/SharesUpdatedListenerTest.php create mode 100644 apps/files/tests/Sharing/SourceNodeTargetManagerTest.php diff --git a/apps/files/appinfo/info.xml b/apps/files/appinfo/info.xml index 6c50df9fba6db..57552b954c7e3 100644 --- a/apps/files/appinfo/info.xml +++ b/apps/files/appinfo/info.xml @@ -10,7 +10,7 @@ Files File Management File Management - 4.0.0-dev.0 + 4.0.0-dev.1 agpl John Molakvoæ Robin Appelman diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php index 1f488adf145bf..8e3d772e0b9c0 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/apps/files/composer/composer/autoload_classmap.php @@ -88,6 +88,7 @@ 'OCA\\Files\\Migration\\Version11301Date20191205150729' => $baseDir . '/../lib/Migration/Version11301Date20191205150729.php', 'OCA\\Files\\Migration\\Version12101Date20221011153334' => $baseDir . '/../lib/Migration/Version12101Date20221011153334.php', 'OCA\\Files\\Migration\\Version2003Date20241021095629' => $baseDir . '/../lib/Migration/Version2003Date20241021095629.php', + 'OCA\\Files\\Migration\\Version35000Date20260910082613' => $baseDir . '/../lib/Migration/Version35000Date20260910082613.php', 'OCA\\Files\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', 'OCA\\Files\\PublicCapabilities' => $baseDir . '/../lib/PublicCapabilities.php', 'OCA\\Files\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php', @@ -109,6 +110,8 @@ 'OCA\\Files\\Sharing\\Permission\\NodeReadSharePermissionType' => $baseDir . '/../lib/Sharing/Permission/NodeReadSharePermissionType.php', 'OCA\\Files\\Sharing\\Permission\\NodeUpdateSharePermissionType' => $baseDir . '/../lib/Sharing/Permission/NodeUpdateSharePermissionType.php', 'OCA\\Files\\Sharing\\Property\\NodeGridViewSharePropertyType' => $baseDir . '/../lib/Sharing/Property/NodeGridViewSharePropertyType.php', + 'OCA\\Files\\Sharing\\SharesUpdatedListener' => $baseDir . '/../lib/Sharing/SharesUpdatedListener.php', + 'OCA\\Files\\Sharing\\SourceNodeTargetManager' => $baseDir . '/../lib/Sharing/SourceNodeTargetManager.php', 'OCA\\Files\\Sharing\\Source\\NodeShareSourceMetadata' => $baseDir . '/../lib/Sharing/Source/NodeShareSourceMetadata.php', 'OCA\\Files\\Sharing\\Source\\NodeShareSourceType' => $baseDir . '/../lib/Sharing/Source/NodeShareSourceType.php', 'OCA\\Files\\TokenScopes' => $baseDir . '/../lib/TokenScopes.php', diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php index 0fc7b8ff788f8..5d34e39c6be2b 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/apps/files/composer/composer/autoload_static.php @@ -103,6 +103,7 @@ class ComposerStaticInitFiles 'OCA\\Files\\Migration\\Version11301Date20191205150729' => __DIR__ . '/..' . '/../lib/Migration/Version11301Date20191205150729.php', 'OCA\\Files\\Migration\\Version12101Date20221011153334' => __DIR__ . '/..' . '/../lib/Migration/Version12101Date20221011153334.php', 'OCA\\Files\\Migration\\Version2003Date20241021095629' => __DIR__ . '/..' . '/../lib/Migration/Version2003Date20241021095629.php', + 'OCA\\Files\\Migration\\Version35000Date20260910082613' => __DIR__ . '/..' . '/../lib/Migration/Version35000Date20260910082613.php', 'OCA\\Files\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', 'OCA\\Files\\PublicCapabilities' => __DIR__ . '/..' . '/../lib/PublicCapabilities.php', 'OCA\\Files\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php', @@ -124,6 +125,8 @@ class ComposerStaticInitFiles 'OCA\\Files\\Sharing\\Permission\\NodeReadSharePermissionType' => __DIR__ . '/..' . '/../lib/Sharing/Permission/NodeReadSharePermissionType.php', 'OCA\\Files\\Sharing\\Permission\\NodeUpdateSharePermissionType' => __DIR__ . '/..' . '/../lib/Sharing/Permission/NodeUpdateSharePermissionType.php', 'OCA\\Files\\Sharing\\Property\\NodeGridViewSharePropertyType' => __DIR__ . '/..' . '/../lib/Sharing/Property/NodeGridViewSharePropertyType.php', + 'OCA\\Files\\Sharing\\SharesUpdatedListener' => __DIR__ . '/..' . '/../lib/Sharing/SharesUpdatedListener.php', + 'OCA\\Files\\Sharing\\SourceNodeTargetManager' => __DIR__ . '/..' . '/../lib/Sharing/SourceNodeTargetManager.php', 'OCA\\Files\\Sharing\\Source\\NodeShareSourceMetadata' => __DIR__ . '/..' . '/../lib/Sharing/Source/NodeShareSourceMetadata.php', 'OCA\\Files\\Sharing\\Source\\NodeShareSourceType' => __DIR__ . '/..' . '/../lib/Sharing/Source/NodeShareSourceType.php', 'OCA\\Files\\TokenScopes' => __DIR__ . '/..' . '/../lib/TokenScopes.php', diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php index 5b6e8221fa6a2..b46409cc2a0d0 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -9,6 +9,7 @@ namespace OCA\Files\AppInfo; +use NCU\Sharing\Event\SharesUpdatedEvent; use NCU\Sharing\ISharingRegistry; use OC\Core\Sharing\Permission\EditSharePermissionPreset; use OC\Core\Sharing\Permission\ViewSharePermissionPreset; @@ -45,6 +46,7 @@ use OCA\Files\Sharing\Permission\NodeReadSharePermissionType; use OCA\Files\Sharing\Permission\NodeUpdateSharePermissionType; use OCA\Files\Sharing\Property\NodeGridViewSharePropertyType; +use OCA\Files\Sharing\SharesUpdatedListener; use OCA\Files\Sharing\Source\NodeShareSourceType; use OCA\Files_Trashbin\Events\MoveToTrashEvent; use OCP\AppFramework\App; @@ -144,6 +146,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(NodeDeletedEvent::class, NodeShareSourceType::class); $context->registerEventListener(MoveToTrashEvent::class, NodeShareSourceType::class); + $context->registerEventListener(SharesUpdatedEvent::class, SharesUpdatedListener::class); } #[\Override] diff --git a/apps/files/lib/Migration/Version35000Date20260910082613.php b/apps/files/lib/Migration/Version35000Date20260910082613.php new file mode 100644 index 0000000000000..fbfa8bd2739d6 --- /dev/null +++ b/apps/files/lib/Migration/Version35000Date20260910082613.php @@ -0,0 +1,38 @@ +createTable('sharing_source_node_target'); + $sourceNodeTargetTable->addColumn('user_id', Types::STRING, ['length' => 64]); + // Can't add a FK on sharing_instance_map here, because that table is created by the sharing app. + $sourceNodeTargetTable->addColumn('source_instance_id', Types::INTEGER, ['notnull' => false]); + $sourceNodeTargetTable->addColumn('source_node_id', Types::INTEGER); + $sourceNodeTargetTable->addColumn('target', Types::TEXT, ['length' => 255]); + // Primary key doesn't work, because that makes source_instance NOT NULL + $sourceNodeTargetTable->addUniqueIndex(['user_id', 'source_instance_id', 'source_node_id']); + + return $schema; + } +} diff --git a/apps/files/lib/Sharing/SharesUpdatedListener.php b/apps/files/lib/Sharing/SharesUpdatedListener.php new file mode 100644 index 0000000000000..fadc6924b5dd6 --- /dev/null +++ b/apps/files/lib/Sharing/SharesUpdatedListener.php @@ -0,0 +1,75 @@ + + */ +final readonly class SharesUpdatedListener implements IEventListener { + public function __construct( + private ISharingRegistry $registry, + private SourceNodeTargetManager $sourceNodeTargetManager, + private IRootFolder $rootFolder, + ) { + } + + #[Override] + public function handle(Event $event): void { + foreach ($event->getShares() as $share) { + if ($share->state !== ShareState::Active) { + continue; + } + + $ownerUserFolder = $this->rootFolder->getUserFolder($share->owner->userId); + + foreach ($share->sources as $source) { + if ($source->class !== NodeShareSourceType::class) { + continue; + } + + $sourceNode = $ownerUserFolder->getFirstNodeById((int)$source->value); + if (!$sourceNode instanceof Node) { + throw new NotFoundException(); + } + + /** @var array */ + $userIds = []; + foreach ($share->recipients as $recipient) { + if ($recipient->instance !== null) { + continue; + } + + if (($recipientType = $this->registry->getRecipientTypes()[$recipient->class] ?? null) === null) { + throw new RuntimeException('The recipient type is not registered: ' . $recipient->class); + } + + foreach ($recipientType->getUsers($recipient->value) as $user) { + $userIds[$user->getUID()] = true; + } + } + + // Casting is needed, because using the user ID as the array key could convert it to an integer if it is numeric. + $this->sourceNodeTargetManager->getTargets(array_map(static fn (int|string $userId): string => (string)$userId, array_keys($userIds)), $share->owner, $sourceNode); + } + } + } +} diff --git a/apps/files/lib/Sharing/SourceNodeTargetManager.php b/apps/files/lib/Sharing/SourceNodeTargetManager.php new file mode 100644 index 0000000000000..6047864e3756b --- /dev/null +++ b/apps/files/lib/Sharing/SourceNodeTargetManager.php @@ -0,0 +1,141 @@ + $userIds + * @return array + */ + private function getTargetsInternal(array $userIds, ShareUser $owner, Node $sourceNode): array { + $targets = []; + + foreach (array_chunk($userIds, 1000) as $chunk) { + $qb = $this->dbConnection->getQueryBuilder(); + $result = $qb + ->select('user_id', 'target') + ->from('sharing_source_node_target') + ->where($qb->expr()->in('user_id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere( + $owner->instance === null + ? $qb->expr()->isNull('source_instance_id') + : $qb->expr()->eq('source_instance_id', $qb->createNamedParameter($this->instanceMapper->getInstanceId($owner->instance), IQueryBuilder::PARAM_INT)) + ) + ->andWhere($qb->expr()->eq('source_node_id', $qb->createNamedParameter($sourceNode->getId(), IQueryBuilder::PARAM_INT))) + ->executeQuery(); + + /** @var list $rows */ + $rows = $result->fetchAllAssociative(); + foreach ($rows as $row) { + $targets[$row['user_id']] = $row['target']; + } + } + + return $targets; + } + + /** + * @throws NotFoundException + */ + public function createDefaultTarget(string $userId, ShareUser $owner, Node $sourceNode): string { + if ($owner->instance !== null) { + throw new RuntimeException('Federation is not supported yet.'); + } + + $shareFolder = $this->config->getSystemValueString('share_folder', '/'); + if ($this->config->getSystemValueBool('sharing.allow_custom_share_folder', true)) { + $shareFolder = $this->userConfig->getValueString($userId, Application::APP_ID, 'share_folder', $shareFolder); + } + + $target = Filesystem::normalizePath($shareFolder . '/' . $sourceNode->getName()); + + $qb = $this->dbConnection->getQueryBuilder(); + $qb + ->insert('sharing_source_node_target') + ->values([ + 'user_id' => $qb->createNamedParameter($userId), + 'source_instance_id' => $qb->createNamedParameter($this->instanceMapper->getInstanceId($owner->instance), IQueryBuilder::PARAM_INT), + 'source_node_id' => $qb->createNamedParameter($sourceNode->getId()), + 'target' => $qb->createNamedParameter($target), + ]) + ->executeStatement(); + + return $target; + } + + public function getTarget(string $userId, ShareUser $owner, Node $sourceNode): ?string { + return $this->getTargetsInternal([$userId], $owner, $sourceNode)[$userId] ?? null; + } + + /** + * @param list $userIds + * @return array + */ + public function getTargets(array $userIds, ShareUser $owner, Node $sourceNode): array { + $targets = $this->getTargetsInternal($userIds, $owner, $sourceNode); + foreach ($userIds as $userId) { + if (isset($targets[$userId])) { + continue; + } + + $targets[$userId] = $this->createDefaultTarget($userId, $owner, $sourceNode); + } + + return $targets; + } + + public function setTarget(string $userId, ShareUser $owner, Node $sourceNode, string $target): void { + $qb = $this->dbConnection->getQueryBuilder(); + $rowCount = $qb + ->update('sharing_source_node_target') + ->set('target', $qb->createNamedParameter($target)) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->andWhere( + $owner->instance === null + ? $qb->expr()->isNull('source_instance_id') + : $qb->expr()->eq('source_instance_id', $qb->createNamedParameter($this->instanceMapper->getInstanceId($owner->instance), IQueryBuilder::PARAM_INT)) + ) + ->andWhere($qb->expr()->eq('source_node_id', $qb->createNamedParameter($sourceNode->getId(), IQueryBuilder::PARAM_INT))) + ->executeStatement(); + + if ($rowCount === 0) { + $qb = $this->dbConnection->getQueryBuilder(); + $qb + ->insert('sharing_source_node_target') + ->values([ + 'user_id' => $qb->createNamedParameter($userId), + 'source_instance_id' => $qb->createNamedParameter($this->instanceMapper->getInstanceId($owner->instance), IQueryBuilder::PARAM_INT), + 'source_node_id' => $qb->createNamedParameter($sourceNode->getId()), + 'target' => $qb->createNamedParameter($target), + ]) + ->executeStatement(); + } + } +} diff --git a/apps/files/tests/Sharing/SharesUpdatedListenerTest.php b/apps/files/tests/Sharing/SharesUpdatedListenerTest.php new file mode 100644 index 0000000000000..5c46669e9d850 --- /dev/null +++ b/apps/files/tests/Sharing/SharesUpdatedListenerTest.php @@ -0,0 +1,119 @@ +owner = $this->createUser('owner', 'password'); + $this->user = $this->createUser('user', 'password'); + + $this->node = Server::get(IRootFolder::class)->getUserFolder($this->owner->getUID())->newFile('foo.txt'); + + $this->dbConnection = Server::get(IDBConnection::class); + $this->sharingManager = Server::get(ISharingManager::class); + $this->sourceNodeTargetManager = Server::get(SourceNodeTargetManager::class); + $this->sharingRegistry = Server::get(ISharingRegistry::class); + + $this->sharingRegistry->clear(); + $this->sharingRegistry->registerSourceType(Server::get(NodeShareSourceType::class)); + $this->sharingRegistry->registerRecipientType(Server::get(UserShareRecipientType::class)); + $this->sharingRegistry->registerPermissionType(NodeShareSourceType::class, Server::get(NodeReadSharePermissionType::class)); + } + + #[Override] + public function tearDown(): void { + $qb = Server::get(IDBConnection::class)->getQueryBuilder(); + $qb + ->delete('sharing_source_node_target') + ->executeStatement(); + + $accessContext = new ShareAccessContext(overrideChecks: true); + $this->dbConnection->beginTransaction(); + foreach ($this->sharingManager->getShares($accessContext, null, null, null, null, null, null) as $share) { + $this->sharingManager->deleteShare($accessContext, $share); + } + + $this->dbConnection->commit(); + + $this->sharingRegistry->clear(); + + parent::tearDown(); + } + + private function getTargetWithoutDefault(): ?string { + /** @psalm-suppress MixedReturnStatement */ + return$this->invokePrivate($this->sourceNodeTargetManager, 'getTarget', [$this->user->getUID(), new ShareUser($this->owner->getUID(), null), $this->node]); + } + + public function test(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + + $share = $this->sharingManager->createShare($accessContext); + $this->assertNull($this->getTargetWithoutDefault()); + + $share = $this->sharingManager->addShareSource($accessContext, $share, new ShareSource(NodeShareSourceType::class, (string)$this->node->getId())); + $this->assertNull($this->getTargetWithoutDefault()); + + $share = $this->sharingManager->addShareRecipient($accessContext, $share, new ShareRecipient(UserShareRecipientType::class, $this->user->getUID(), null)); + $this->assertNull($this->getTargetWithoutDefault()); + + $share = $this->sharingManager->updateSharePermission($accessContext, $share, new SharePermission(NodeReadSharePermissionType::class, true)); + $this->assertNull($this->getTargetWithoutDefault()); + + $this->sharingManager->updateShareState($accessContext, $share, ShareState::Active); + $this->assertEquals('/foo.txt', $this->getTargetWithoutDefault()); + + $this->dbConnection->commit(); + } +} diff --git a/apps/files/tests/Sharing/SourceNodeTargetManagerTest.php b/apps/files/tests/Sharing/SourceNodeTargetManagerTest.php new file mode 100644 index 0000000000000..5fd7393fba239 --- /dev/null +++ b/apps/files/tests/Sharing/SourceNodeTargetManagerTest.php @@ -0,0 +1,104 @@ +owner = $this->createUser('owner', 'password'); + $this->user = $this->createUser('user', 'password'); + + $this->node = Server::get(IRootFolder::class)->getUserFolder($this->owner->getUID())->newFile('foo.txt'); + + $this->manager = Server::get(SourceNodeTargetManager::class); + } + + #[Override] + public function tearDown(): void { + $qb = Server::get(IDBConnection::class)->getQueryBuilder(); + $qb + ->delete('sharing_source_node_target') + ->executeStatement(); + + parent::tearDown(); + } + + public function testCreateDefaultTarget(): void { + $this->assertEquals('/foo.txt', $this->manager->createDefaultTarget($this->user->getUID(), new ShareUser($this->owner->getUID(), null), $this->node)); + } + + public function testCreateDefaultSystem(): void { + $config = Server::get(IConfig::class); + + $config->setSystemValue('share_folder', '/system'); + $this->assertEquals('/system/foo.txt', $this->manager->createDefaultTarget($this->user->getUID(), new ShareUser($this->owner->getUID(), null), $this->node)); + + $config->deleteSystemValue('share_folder'); + } + + public function testCreateDefaultUser(): void { + $userConfig = Server::get(IUserConfig::class); + + $userConfig->setValueString($this->user->getUID(), Application::APP_ID, 'share_folder', '/user'); + $this->assertEquals('/user/foo.txt', $this->manager->createDefaultTarget($this->user->getUID(), new ShareUser($this->owner->getUID(), null), $this->node)); + + $userConfig->deleteUserConfig($this->user->getUID(), Application::APP_ID, 'share_folder'); + } + + public function testCreateDefaultUserNotAllowed(): void { + $config = Server::get(IConfig::class); + $userConfig = Server::get(IUserConfig::class); + + $config->setSystemValue('sharing.allow_custom_share_folder', false); + $userConfig->setValueString($this->user->getUID(), Application::APP_ID, 'share_folder', '/user'); + $this->assertEquals('/foo.txt', $this->manager->createDefaultTarget($this->user->getUID(), new ShareUser($this->owner->getUID(), null), $this->node)); + + $config->deleteSystemValue('sharing.allow_custom_share_folder'); + $userConfig->deleteUserConfig($this->user->getUID(), Application::APP_ID, 'share_folder'); + } + + public function testSetAndGetTarget(): void { + $this->assertNull($this->manager->getTarget($this->user->getUID(), new ShareUser($this->owner->getUID(), null), $this->node)); + + $this->manager->setTarget($this->user->getUID(), new ShareUser($this->owner->getUID(), null), $this->node, '/bar.txt'); + $this->assertEquals('/bar.txt', $this->manager->getTarget($this->user->getUID(), new ShareUser($this->owner->getUID(), null), $this->node)); + + $this->manager->setTarget($this->user->getUID(), new ShareUser($this->owner->getUID(), null), $this->node, '/baz.txt'); + $this->assertEquals('/baz.txt', $this->manager->getTarget($this->user->getUID(), new ShareUser($this->owner->getUID(), null), $this->node)); + } +} diff --git a/build/rector-strict.php b/build/rector-strict.php index 638bb388946d2..bb3e0924ce6bb 100644 --- a/build/rector-strict.php +++ b/build/rector-strict.php @@ -65,6 +65,7 @@ $nextcloudDir . '/tests/lib/SystemReport', $nextcloudDir . '/lib/public/Share/Events/ShareUpdatedEvent.php', $nextcloudDir . '/lib/public/Share/Events/ShareRestoredEvent.php', + $nextcloudDir . '/apps/files/lib/Migration/Version35000Date20260910082613.php', ]) ->withAutoloadPaths([ // ensure rector properly autoload the public interfaces diff --git a/psalm-strict.xml b/psalm-strict.xml index 6990962ffe6fc..3fd98bc95f952 100644 --- a/psalm-strict.xml +++ b/psalm-strict.xml @@ -71,6 +71,7 @@ + From 07892c398275d46c346e08dd5838af40084cadcd Mon Sep 17 00:00:00 2001 From: provokateurin Date: Mon, 21 Sep 2026 16:26:50 +0200 Subject: [PATCH 12/23] refactor(ISharePropertyTypeModifyValue): Modify values on format instead of load Signed-off-by: provokateurin --- lib/private/Sharing/SharingBackend.php | 9 --------- lib/private/Sharing/SharingManager.php | 4 ---- .../Property/APasswordSharePropertyType.php | 2 +- .../Property/ISharePropertyTypeModifyValue.php | 4 ++-- lib/unstable/Sharing/Property/ShareProperty.php | 7 ++++++- tests/lib/Sharing/AbstractSharingManagerTests.php | 14 +++++++------- .../Property/APasswordSharePropertyTypeTest.php | 6 +++--- .../Sharing/TestSharePropertyTypeModifyValue.php | 6 +++--- 8 files changed, 22 insertions(+), 30 deletions(-) diff --git a/lib/private/Sharing/SharingBackend.php b/lib/private/Sharing/SharingBackend.php index a14ad36a5105c..eaa67700d8e15 100644 --- a/lib/private/Sharing/SharingBackend.php +++ b/lib/private/Sharing/SharingBackend.php @@ -1119,12 +1119,6 @@ private function list( /** @var ?string $value */ $value = $row['property_value']; - - $propertyType = $registryPropertyTypes[$propertyTypeClass]; - if ($propertyType instanceof ISharePropertyTypeModifyValue) { - $value = $propertyType->modifyValueOnLoad($value); - } - $shares[$shareId]['properties'][$propertyTypeClass] = new ShareProperty($propertyTypeClass, $value); } } @@ -1292,9 +1286,6 @@ public function createSharePropertyDefaultValue(Share $share, string $propertyTy $property = new ShareProperty($propertyTypeClass, $propertyType->getDefaultValue($share)); $value = $this->updateShareProperty($share->id, $property); - if ($propertyType instanceof ISharePropertyTypeModifyValue) { - $value = $propertyType->modifyValueOnLoad($value); - } $property = new ShareProperty( $property->class, diff --git a/lib/private/Sharing/SharingManager.php b/lib/private/Sharing/SharingManager.php index 97fd550dc6fea..fbab53a02b954 100644 --- a/lib/private/Sharing/SharingManager.php +++ b/lib/private/Sharing/SharingManager.php @@ -20,7 +20,6 @@ use NCU\Sharing\ISharingRegistry; use NCU\Sharing\Permission\ISharePermissionType; use NCU\Sharing\Permission\SharePermission; -use NCU\Sharing\Property\ISharePropertyTypeModifyValue; use NCU\Sharing\Property\ShareProperty; use NCU\Sharing\Recipient\IShareRecipientType; use NCU\Sharing\Recipient\IShareRecipientTypePublicSecret; @@ -646,9 +645,6 @@ public function updateShareProperty(ShareAccessContext $accessContext, Share $sh $this->backend->setLastUpdated([$share->id], $time); $value = $this->backend->updateShareProperty($share->id, $property); - if ($propertyType instanceof ISharePropertyTypeModifyValue) { - $value = $propertyType->modifyValueOnLoad($value); - } $property = new ShareProperty( $property->class, diff --git a/lib/unstable/Sharing/Property/APasswordSharePropertyType.php b/lib/unstable/Sharing/Property/APasswordSharePropertyType.php index 4ff5ba8a62eb3..9121a5a9af968 100644 --- a/lib/unstable/Sharing/Property/APasswordSharePropertyType.php +++ b/lib/unstable/Sharing/Property/APasswordSharePropertyType.php @@ -80,7 +80,7 @@ public function modifyValueOnSave(?string $oldValue, ?string $newValue): ?string * @experimental 35.0.0 */ #[\Override] - public function modifyValueOnLoad(?string $value): ?string { + public function modifyValueOnFormat(?string $value): ?string { if ($value === null) { return null; } diff --git a/lib/unstable/Sharing/Property/ISharePropertyTypeModifyValue.php b/lib/unstable/Sharing/Property/ISharePropertyTypeModifyValue.php index 268d018036ee4..a10c6788b6516 100644 --- a/lib/unstable/Sharing/Property/ISharePropertyTypeModifyValue.php +++ b/lib/unstable/Sharing/Property/ISharePropertyTypeModifyValue.php @@ -26,9 +26,9 @@ interface ISharePropertyTypeModifyValue extends ISharePropertyType { public function modifyValueOnSave(?string $oldValue, ?string $newValue): ?string; /** - * Modify the value whenever a share is fetched from the database. + * Modify the value whenever a share is formatted for the output. * * @experimental 35.0.0 */ - public function modifyValueOnLoad(?string $value): ?string; + public function modifyValueOnFormat(?string $value): ?string; } diff --git a/lib/unstable/Sharing/Property/ShareProperty.php b/lib/unstable/Sharing/Property/ShareProperty.php index 2894424b97f02..b064f60a9ca4b 100644 --- a/lib/unstable/Sharing/Property/ShareProperty.php +++ b/lib/unstable/Sharing/Property/ShareProperty.php @@ -44,6 +44,11 @@ public function format(ISharingRegistry $registry, IFactory $l10nFactory, Share throw new RuntimeException('The property type is not registered: ' . $this->class); } + $value = $this->value; + if ($propertyType instanceof ISharePropertyTypeModifyValue) { + $value = $propertyType->modifyValueOnFormat($value); + } + return $propertyType->format($share, [ 'class' => $this->class, 'display_name' => $propertyType->getDisplayName($l10nFactory), @@ -51,7 +56,7 @@ public function format(ISharingRegistry $registry, IFactory $l10nFactory, Share 'priority' => $propertyType->getPriority(), 'advanced' => $propertyType->isAdvanced(), 'required' => $propertyType->isRequired($share), - 'value' => $this->value, + 'value' => $value, ]); } diff --git a/tests/lib/Sharing/AbstractSharingManagerTests.php b/tests/lib/Sharing/AbstractSharingManagerTests.php index ea32dc7bd4c42..cda5b2394c0a6 100644 --- a/tests/lib/Sharing/AbstractSharingManagerTests.php +++ b/tests/lib/Sharing/AbstractSharingManagerTests.php @@ -1750,7 +1750,7 @@ public function testUpdateSharePropertyRequired(): void { public function testUpdateSharePropertyModifyProperties(): void { $this->registry->registerPropertyType( - new TestSharePropertyTypeModifyValue(['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-load']) + new TestSharePropertyTypeModifyValue(['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-format']) ); $this->registry->markPropertyTypeCompatibleWithSourceType(TestSharePropertyTypeModifyValue::class, TestShareSourceType1::class); $this->registry->markPropertyTypeCompatibleWithRecipientType(TestSharePropertyTypeModifyValue::class, TestShareRecipientType1::class); @@ -1788,7 +1788,7 @@ public function testUpdateSharePropertyModifyProperties(): void { 'required' => false, 'value' => 'modified-on-save', 'type' => 'enum', - 'valid_values' => ['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-load'], + 'valid_values' => ['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-format'], ], ], $formatted['properties']); @@ -1822,7 +1822,7 @@ public function testUpdateSharePropertyModifyProperties(): void { 'required' => false, 'value' => 'old-value', 'type' => 'enum', - 'valid_values' => ['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-load'], + 'valid_values' => ['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-format'], ], ], $formatted['properties']); @@ -1852,12 +1852,12 @@ public function testUpdateSharePropertyModifyProperties(): void { 'required' => false, 'value' => 'modified-on-save', 'type' => 'enum', - 'valid_values' => ['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-load'], + 'valid_values' => ['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-format'], ], ], $formatted['properties']); $before = $this->manager->getTime(); - $formatted = $this->updateShareProperty($accessContext, $share, new ShareProperty(TestSharePropertyTypeModifyValue::class, 'modify-on-load')); + $formatted = $this->updateShareProperty($accessContext, $share, new ShareProperty(TestSharePropertyTypeModifyValue::class, 'modify-on-format')); $after = $this->manager->getTime(); $this->assertDateBetween($before, $after, $this->parseTime($formatted['last_updated'])); $this->assertEquals([ @@ -1879,9 +1879,9 @@ public function testUpdateSharePropertyModifyProperties(): void { 'priority' => 1, 'advanced' => false, 'required' => false, - 'value' => 'modified-on-load', + 'value' => 'modified-on-format', 'type' => 'enum', - 'valid_values' => ['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-load'], + 'valid_values' => ['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-format'], ], ], $formatted['properties']); } diff --git a/tests/lib/Sharing/Property/APasswordSharePropertyTypeTest.php b/tests/lib/Sharing/Property/APasswordSharePropertyTypeTest.php index 1d104c72ce32b..019dab10ce313 100644 --- a/tests/lib/Sharing/Property/APasswordSharePropertyTypeTest.php +++ b/tests/lib/Sharing/Property/APasswordSharePropertyTypeTest.php @@ -105,9 +105,9 @@ public function testValidateValue(): void { $this->assertIsString($this->propertyType->validateValue($l10nFactory, $share, '123')); } - public function testModifyValueOnFetch(): void { - $this->assertNull($this->propertyType->modifyValueOnLoad(null)); - $this->assertEquals(APasswordSharePropertyType::PLACEHOLDER, $this->propertyType->modifyValueOnLoad('')); + public function testModifyValueOnFormat(): void { + $this->assertNull($this->propertyType->modifyValueOnFormat(null)); + $this->assertEquals(APasswordSharePropertyType::PLACEHOLDER, $this->propertyType->modifyValueOnFormat('')); } public function testModifyValueOnSave(): void { diff --git a/tests/lib/Sharing/TestSharePropertyTypeModifyValue.php b/tests/lib/Sharing/TestSharePropertyTypeModifyValue.php index fd67691631bcd..9c532d680fdab 100644 --- a/tests/lib/Sharing/TestSharePropertyTypeModifyValue.php +++ b/tests/lib/Sharing/TestSharePropertyTypeModifyValue.php @@ -33,9 +33,9 @@ public function modifyValueOnSave(?string $oldValue, ?string $newValue): ?string } #[\Override] - public function modifyValueOnLoad(?string $value): ?string { - if ($value === 'modify-on-load') { - return 'modified-on-load'; + public function modifyValueOnFormat(?string $value): ?string { + if ($value === 'modify-on-format') { + return 'modified-on-format'; } return $value; From d86be3b6b0b7044775dce9c8925f446d3b25c635 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 3 Nov 2026 11:23:51 +0100 Subject: [PATCH 13/23] refactor(ISharePropertyTypeModifyValue): Modify values on save in the manager instead of the backend Signed-off-by: provokateurin --- lib/private/Sharing/SharingBackend.php | 59 ++++----- lib/private/Sharing/SharingManager.php | 8 +- lib/unstable/Sharing/ISharingBackend.php | 13 +- .../Sharing/AbstractSharingManagerTests.php | 123 ++++-------------- .../TestSharePropertyTypeModifyValue.php | 7 - 5 files changed, 69 insertions(+), 141 deletions(-) diff --git a/lib/private/Sharing/SharingBackend.php b/lib/private/Sharing/SharingBackend.php index eaa67700d8e15..4605b9a1f61ad 100644 --- a/lib/private/Sharing/SharingBackend.php +++ b/lib/private/Sharing/SharingBackend.php @@ -19,7 +19,6 @@ use NCU\Sharing\Permission\SharePermission; use NCU\Sharing\Property\ISharePropertyType; use NCU\Sharing\Property\ISharePropertyTypeFilter; -use NCU\Sharing\Property\ISharePropertyTypeModifyValue; use NCU\Sharing\Property\ShareProperty; use NCU\Sharing\Recipient\IShareRecipientType; use NCU\Sharing\Recipient\ShareRecipient; @@ -381,38 +380,13 @@ public function updateShareRecipientSecret(string $id, ShareRecipient $recipient } #[\Override] - public function updateShareProperty(string $id, ShareProperty $property): ?string { + public function updateShareProperty(string $id, ShareProperty $property): void { $this->assertInTransaction(); - $value = $property->value; - - $propertyType = $this->registry->getPropertyTypes()[$property->class]; - - if ($propertyType instanceof ISharePropertyTypeModifyValue) { - $qb = $this->connection->getQueryBuilder(); - $qb - ->select('sp.property_value') - ->from('sharing_share_properties', 'sp') - ->where($qb->expr()->eq('sp.share_id', $qb->createNamedParameter($id))) - ->andWhere( - $qb->expr()->eq( - 'sp.property_class_id', $qb->createNamedParameter($this->classMapper->getClassId($property->class), IQueryBuilder::PARAM_INT) - ) - ); - - /** @var string|false $oldValue */ - $oldValue = $qb->executeQuery()->fetchOne(); - if ($oldValue === false) { - $oldValue = null; - } - - $value = $propertyType->modifyValueOnSave($oldValue, $property->value); - } - $qb = $this->connection->getQueryBuilder(); $rowCount = $qb ->update('sharing_share_properties') - ->set('property_value', $qb->createNamedParameter($value)) + ->set('property_value', $qb->createNamedParameter($property->value)) ->where($qb->expr()->eq('share_id', $qb->createNamedParameter($id))) ->andWhere( $qb->expr()->eq('property_class_id', $qb->createNamedParameter($this->classMapper->getClassId($property->class), IQueryBuilder::PARAM_INT)) @@ -425,10 +399,30 @@ public function updateShareProperty(string $id, ShareProperty $property): ?strin ->values([ 'share_id' => $qb->createNamedParameter($id), 'property_class_id' => $qb->createNamedParameter($this->classMapper->getClassId($property->class), IQueryBuilder::PARAM_INT), - 'property_value' => $qb->createNamedParameter($value), + 'property_value' => $qb->createNamedParameter($property->value), ]) ->executeStatement(); } + } + + #[\Override] + public function getSharePropertyValue(string $id, string $propertyTypeClass): ?string { + $qb = $this->connection->getQueryBuilder(); + $qb + ->select('sp.property_value') + ->from('sharing_share_properties', 'sp') + ->where($qb->expr()->eq('sp.share_id', $qb->createNamedParameter($id))) + ->andWhere( + $qb->expr()->eq( + 'sp.property_class_id', $qb->createNamedParameter($this->classMapper->getClassId($propertyTypeClass), IQueryBuilder::PARAM_INT) + ) + ); + + /** @var string|false $value */ + $value = $qb->executeQuery()->fetchOne(); + if ($value === false) { + return null; + } return $value; } @@ -1285,12 +1279,7 @@ public function createSharePropertyDefaultValue(Share $share, string $propertyTy $property = new ShareProperty($propertyTypeClass, $propertyType->getDefaultValue($share)); - $value = $this->updateShareProperty($share->id, $property); - - $property = new ShareProperty( - $property->class, - $value, - ); + $this->updateShareProperty($share->id, $property); $properties = $share->properties; $properties[$propertyTypeClass] = $property; diff --git a/lib/private/Sharing/SharingManager.php b/lib/private/Sharing/SharingManager.php index fbab53a02b954..4725f8912ab7f 100644 --- a/lib/private/Sharing/SharingManager.php +++ b/lib/private/Sharing/SharingManager.php @@ -20,6 +20,7 @@ use NCU\Sharing\ISharingRegistry; use NCU\Sharing\Permission\ISharePermissionType; use NCU\Sharing\Permission\SharePermission; +use NCU\Sharing\Property\ISharePropertyTypeModifyValue; use NCU\Sharing\Property\ShareProperty; use NCU\Sharing\Recipient\IShareRecipientType; use NCU\Sharing\Recipient\IShareRecipientTypePublicSecret; @@ -644,13 +645,18 @@ public function updateShareProperty(ShareAccessContext $accessContext, Share $sh $time = $this->getTime(); $this->backend->setLastUpdated([$share->id], $time); - $value = $this->backend->updateShareProperty($share->id, $property); + $value = $property->value; + if ($propertyType instanceof ISharePropertyTypeModifyValue) { + $value = $propertyType->modifyValueOnSave($this->backend->getSharePropertyValue($share->id, $property->class), $value); + } $property = new ShareProperty( $property->class, $value, ); + $this->backend->updateShareProperty($share->id, $property); + $properties = $share->properties; $properties[$property->class] = $property; diff --git a/lib/unstable/Sharing/ISharingBackend.php b/lib/unstable/Sharing/ISharingBackend.php index 2d2a86feb2094..b7f584b45d34d 100644 --- a/lib/unstable/Sharing/ISharingBackend.php +++ b/lib/unstable/Sharing/ISharingBackend.php @@ -13,6 +13,7 @@ use NCU\Sharing\Exception\ShareNotFoundException; use NCU\Sharing\Permission\ISharePermissionPreset; use NCU\Sharing\Permission\SharePermission; +use NCU\Sharing\Property\ISharePropertyType; use NCU\Sharing\Property\ShareProperty; use NCU\Sharing\Recipient\IShareRecipientType; use NCU\Sharing\Recipient\ShareRecipient; @@ -138,7 +139,17 @@ public function updateShareRecipientSecret(string $id, ShareRecipient $recipient * @throws ShareNotFoundException * @experimental 35.0.0 */ - public function updateShareProperty(string $id, ShareProperty $property): ?string; + public function updateShareProperty(string $id, ShareProperty $property): void; + + /** + * Get the value of a property of a share. + * + * @param non-empty-string $id + * @param class-string $propertyTypeClass + * @throws ShareNotFoundException + * @experimental 35.0.0 + */ + public function getSharePropertyValue(string $id, string $propertyTypeClass): ?string; /** * Update a permission of a share. diff --git a/tests/lib/Sharing/AbstractSharingManagerTests.php b/tests/lib/Sharing/AbstractSharingManagerTests.php index cda5b2394c0a6..3af97a6019647 100644 --- a/tests/lib/Sharing/AbstractSharingManagerTests.php +++ b/tests/lib/Sharing/AbstractSharingManagerTests.php @@ -1748,7 +1748,24 @@ public function testUpdateSharePropertyRequired(): void { ], $formatted['properties']); } - public function testUpdateSharePropertyModifyProperties(): void { + /** + * @return list, ?string}> + */ + public static function dataUpdateSharePropertyModifyProperties(): array { + return [ + [[], null], + [['modify-on-save'], 'modified-on-save'], + [['old-value'], 'old-value'], + [['old-value', 'modify-on-save-old-value'], 'old-value'], + [['modify-on-format'], 'modified-on-format'], + ]; + } + + /** + * @param list $values + */ + #[DataProvider('dataUpdateSharePropertyModifyProperties')] + public function testUpdateSharePropertyModifyProperties(array $values, ?string $expectedValue): void { $this->registry->registerPropertyType( new TestSharePropertyTypeModifyValue(['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-format']) ); @@ -1764,102 +1781,14 @@ public function testUpdateSharePropertyModifyProperties(): void { $this->dbConnection->commit(); - // We cannot test for modify-on-save, because we will always see modified-on-save as the returned value. - - $formatted = $this->getShare($accessContext, $share->id); - $this->assertEquals([ - [ - 'class' => TestSharePropertyType1::class, - 'display_name' => 'TestSharePropertyType1', - 'hint' => 'hint TestSharePropertyType1', - 'priority' => 1, - 'advanced' => false, - 'required' => false, - 'value' => null, - 'type' => 'enum', - 'valid_values' => ['valid1'], - ], - [ - 'class' => TestSharePropertyTypeModifyValue::class, - 'display_name' => 'TestSharePropertyTypeModifyValue', - 'hint' => 'hint TestSharePropertyTypeModifyValue', - 'priority' => 1, - 'advanced' => false, - 'required' => false, - 'value' => 'modified-on-save', - 'type' => 'enum', - 'valid_values' => ['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-format'], - ], - ], $formatted['properties']); - - $this->dbConnection->beginTransaction(); - $share = $this->manager->updateShareProperty($accessContext, $share, new ShareProperty(TestSharePropertyTypeModifyValue::class, 'old-value')); - $this->dbConnection->commit(); - - $before = $this->manager->getTime(); - $formatted = $this->updateShareProperty($accessContext, $share, new ShareProperty(TestSharePropertyTypeModifyValue::class, 'modify-on-save-old-value')); - $share = $this->reloadShare($accessContext, $share); - $after = $this->manager->getTime(); - $this->assertDateBetween($before, $after, $this->parseTime($formatted['last_updated'])); - $this->assertEquals([ - [ - 'class' => TestSharePropertyType1::class, - 'display_name' => 'TestSharePropertyType1', - 'hint' => 'hint TestSharePropertyType1', - 'priority' => 1, - 'advanced' => false, - 'required' => false, - 'value' => null, - 'type' => 'enum', - 'valid_values' => ['valid1'], - ], - [ - 'class' => TestSharePropertyTypeModifyValue::class, - 'display_name' => 'TestSharePropertyTypeModifyValue', - 'hint' => 'hint TestSharePropertyTypeModifyValue', - 'priority' => 1, - 'advanced' => false, - 'required' => false, - 'value' => 'old-value', - 'type' => 'enum', - 'valid_values' => ['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-format'], - ], - ], $formatted['properties']); - - $before = $this->manager->getTime(); - $formatted = $this->updateShareProperty($accessContext, $share, new ShareProperty(TestSharePropertyTypeModifyValue::class, 'modify-on-save')); - $share = $this->reloadShare($accessContext, $share); - $after = $this->manager->getTime(); - $this->assertDateBetween($before, $after, $this->parseTime($formatted['last_updated'])); - $this->assertEquals([ - [ - 'class' => TestSharePropertyType1::class, - 'display_name' => 'TestSharePropertyType1', - 'hint' => 'hint TestSharePropertyType1', - 'priority' => 1, - 'advanced' => false, - 'required' => false, - 'value' => null, - 'type' => 'enum', - 'valid_values' => ['valid1'], - ], - [ - 'class' => TestSharePropertyTypeModifyValue::class, - 'display_name' => 'TestSharePropertyTypeModifyValue', - 'hint' => 'hint TestSharePropertyTypeModifyValue', - 'priority' => 1, - 'advanced' => false, - 'required' => false, - 'value' => 'modified-on-save', - 'type' => 'enum', - 'valid_values' => ['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-format'], - ], - ], $formatted['properties']); + if ($values === []) { + $formatted = $this->getShare($accessContext, $share->id); + } else { + foreach ($values as $value) { + $formatted = $this->updateShareProperty($accessContext, $share, new ShareProperty(TestSharePropertyTypeModifyValue::class, $value)); + } + } - $before = $this->manager->getTime(); - $formatted = $this->updateShareProperty($accessContext, $share, new ShareProperty(TestSharePropertyTypeModifyValue::class, 'modify-on-format')); - $after = $this->manager->getTime(); - $this->assertDateBetween($before, $after, $this->parseTime($formatted['last_updated'])); $this->assertEquals([ [ 'class' => TestSharePropertyType1::class, @@ -1879,7 +1808,7 @@ public function testUpdateSharePropertyModifyProperties(): void { 'priority' => 1, 'advanced' => false, 'required' => false, - 'value' => 'modified-on-format', + 'value' => $expectedValue, 'type' => 'enum', 'valid_values' => ['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-format'], ], diff --git a/tests/lib/Sharing/TestSharePropertyTypeModifyValue.php b/tests/lib/Sharing/TestSharePropertyTypeModifyValue.php index 9c532d680fdab..c6fce3c501645 100644 --- a/tests/lib/Sharing/TestSharePropertyTypeModifyValue.php +++ b/tests/lib/Sharing/TestSharePropertyTypeModifyValue.php @@ -10,15 +10,8 @@ namespace Test\Sharing; use NCU\Sharing\Property\ISharePropertyTypeModifyValue; -use NCU\Sharing\Share; final class TestSharePropertyTypeModifyValue extends TestSharePropertyType1 implements ISharePropertyTypeModifyValue { - - #[\Override] - public function getDefaultValue(Share $share): string { - return 'modify-on-save'; - } - #[\Override] public function modifyValueOnSave(?string $oldValue, ?string $newValue): ?string { if ($newValue === 'modify-on-save') { From f070496ee13f5a335a0cd23a97717bbde18694d4 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 23 Sep 2026 14:00:34 +0200 Subject: [PATCH 14/23] feat(Share): Add getCreatedAt() method Signed-off-by: provokateurin --- lib/unstable/Sharing/Share.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/unstable/Sharing/Share.php b/lib/unstable/Sharing/Share.php index 55a11f6e15710..12e1bb5d148d4 100644 --- a/lib/unstable/Sharing/Share.php +++ b/lib/unstable/Sharing/Share.php @@ -9,6 +9,7 @@ namespace NCU\Sharing; +use DateTimeImmutable; use NCU\Sharing\Permission\ISharePermissionPreset; use NCU\Sharing\Permission\ISharePermissionType; use NCU\Sharing\Permission\SharePermission; @@ -24,6 +25,7 @@ use OCP\IUserManager; use OCP\L10N\IFactory; use OCP\Server; +use OCP\Snowflake\ISnowflakeDecoder; /** * Keep the following types in sync with apps/sharing/lib/ResponseDefinitions.php: @@ -150,6 +152,8 @@ */ #[Consumable(since: '35.0.0')] final class Share { + private ?DateTimeImmutable $createdAt = null; + /** @var array> $recipientsCache */ private array $recipientsCache = []; @@ -177,6 +181,13 @@ public function __construct( ) { } + /** + * @experimental 35.0.0 + */ + public function getCreatedAt(): DateTimeImmutable { + return $this->createdAt ??= Server::get(ISnowflakeDecoder::class)->decode($this->id)->getCreatedAt(); + } + /** * @return list * @experimental 35.0.0 From e52ee506b1d2883b85caf62d2125a1f054c18bb8 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 23 Sep 2026 14:40:21 +0200 Subject: [PATCH 15/23] feat(ISharingBackend): Add getUserStatuses() method Signed-off-by: provokateurin --- lib/private/Sharing/SharingBackend.php | 23 +++++++++++++++++++++++ lib/unstable/Sharing/ISharingBackend.php | 7 +++++++ 2 files changed, 30 insertions(+) diff --git a/lib/private/Sharing/SharingBackend.php b/lib/private/Sharing/SharingBackend.php index 4605b9a1f61ad..5db5d59a8c147 100644 --- a/lib/private/Sharing/SharingBackend.php +++ b/lib/private/Sharing/SharingBackend.php @@ -1449,4 +1449,27 @@ private function isNullOrEqual(IQueryBuilder $query, string $field, null|int|str ? $query->expr()->isNull($field) : $query->expr()->eq($field, $query->createNamedParameter($value), is_int($value) ? IQueryBuilder::PARAM_INT : IQueryBuilder::PARAM_STR); } + + #[\Override] + public function getUserStatuses(string $id, array $userIds): array { + $this->assertInTransaction(); + + $userStatuses = []; + foreach (array_chunk($userIds, 1000) as $chunk) { + $qb = $this->connection->getQueryBuilder(); + $result = $qb + ->select('user_id', 'status_id') + ->from('sharing_share_user_status') + ->where($qb->expr()->eq('share_id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->in('user_id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_STR_ARRAY))) + ->executeQuery(); + /** @var list */ + $rows = $result->fetchAll(); + foreach ($rows as $row) { + $userStatuses[$row['user_id']] = ShareUserStatus::decode((int)$row['status_id']); + } + } + + return $userStatuses; + } } diff --git a/lib/unstable/Sharing/ISharingBackend.php b/lib/unstable/Sharing/ISharingBackend.php index b7f584b45d34d..8a74895c721be 100644 --- a/lib/unstable/Sharing/ISharingBackend.php +++ b/lib/unstable/Sharing/ISharingBackend.php @@ -255,4 +255,11 @@ public function getRecommendedRecipients( int $count = 5, int $offset = 0, ): array; + + /** + * @param list $userIds + * @return array + * @experimental 35.0.0 + */ + public function getUserStatuses(string $id, array $userIds): array; } From 90cb1d0095291030d16bfea5b75c64ddcc5fa82c Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 13 Oct 2026 10:18:47 +0200 Subject: [PATCH 16/23] feat(files): Add NodeNicknameSharePropertyType Signed-off-by: provokateurin --- .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + apps/files/lib/AppInfo/Application.php | 5 ++ .../NodeNicknameSharePropertyType.php | 47 +++++++++++++++++++ 4 files changed, 54 insertions(+) create mode 100644 apps/files/lib/Sharing/Property/NodeNicknameSharePropertyType.php diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php index 8e3d772e0b9c0..73aa1e7a3957e 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/apps/files/composer/composer/autoload_classmap.php @@ -110,6 +110,7 @@ 'OCA\\Files\\Sharing\\Permission\\NodeReadSharePermissionType' => $baseDir . '/../lib/Sharing/Permission/NodeReadSharePermissionType.php', 'OCA\\Files\\Sharing\\Permission\\NodeUpdateSharePermissionType' => $baseDir . '/../lib/Sharing/Permission/NodeUpdateSharePermissionType.php', 'OCA\\Files\\Sharing\\Property\\NodeGridViewSharePropertyType' => $baseDir . '/../lib/Sharing/Property/NodeGridViewSharePropertyType.php', + 'OCA\\Files\\Sharing\\Property\\NodeNicknameSharePropertyType' => $baseDir . '/../lib/Sharing/Property/NodeNicknameSharePropertyType.php', 'OCA\\Files\\Sharing\\SharesUpdatedListener' => $baseDir . '/../lib/Sharing/SharesUpdatedListener.php', 'OCA\\Files\\Sharing\\SourceNodeTargetManager' => $baseDir . '/../lib/Sharing/SourceNodeTargetManager.php', 'OCA\\Files\\Sharing\\Source\\NodeShareSourceMetadata' => $baseDir . '/../lib/Sharing/Source/NodeShareSourceMetadata.php', diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php index 5d34e39c6be2b..c03f7d44da5a5 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/apps/files/composer/composer/autoload_static.php @@ -125,6 +125,7 @@ class ComposerStaticInitFiles 'OCA\\Files\\Sharing\\Permission\\NodeReadSharePermissionType' => __DIR__ . '/..' . '/../lib/Sharing/Permission/NodeReadSharePermissionType.php', 'OCA\\Files\\Sharing\\Permission\\NodeUpdateSharePermissionType' => __DIR__ . '/..' . '/../lib/Sharing/Permission/NodeUpdateSharePermissionType.php', 'OCA\\Files\\Sharing\\Property\\NodeGridViewSharePropertyType' => __DIR__ . '/..' . '/../lib/Sharing/Property/NodeGridViewSharePropertyType.php', + 'OCA\\Files\\Sharing\\Property\\NodeNicknameSharePropertyType' => __DIR__ . '/..' . '/../lib/Sharing/Property/NodeNicknameSharePropertyType.php', 'OCA\\Files\\Sharing\\SharesUpdatedListener' => __DIR__ . '/..' . '/../lib/Sharing/SharesUpdatedListener.php', 'OCA\\Files\\Sharing\\SourceNodeTargetManager' => __DIR__ . '/..' . '/../lib/Sharing/SourceNodeTargetManager.php', 'OCA\\Files\\Sharing\\Source\\NodeShareSourceMetadata' => __DIR__ . '/..' . '/../lib/Sharing/Source/NodeShareSourceMetadata.php', diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php index b46409cc2a0d0..c98eb8d65681c 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -46,6 +46,7 @@ use OCA\Files\Sharing\Permission\NodeReadSharePermissionType; use OCA\Files\Sharing\Permission\NodeUpdateSharePermissionType; use OCA\Files\Sharing\Property\NodeGridViewSharePropertyType; +use OCA\Files\Sharing\Property\NodeNicknameSharePropertyType; use OCA\Files\Sharing\SharesUpdatedListener; use OCA\Files\Sharing\Source\NodeShareSourceType; use OCA\Files_Trashbin\Events\MoveToTrashEvent; @@ -127,6 +128,10 @@ public function register(IRegistrationContext $context): void { $registry->markPropertyTypeCompatibleWithSourceType(NodeGridViewSharePropertyType::class, NodeShareSourceType::class); $registry->markPropertyTypeCompatibleWithRecipientType(NodeGridViewSharePropertyType::class, TokenShareRecipientType::class); + $registry->registerPropertyType(new NodeNicknameSharePropertyType()); + $registry->markPropertyTypeCompatibleWithSourceType(NodeNicknameSharePropertyType::class, NodeShareSourceType::class); + $registry->markPropertyTypeCompatibleWithRecipientType(NodeNicknameSharePropertyType::class, TokenShareRecipientType::class); + $registry->registerPermissionType(NodeShareSourceType::class, Server::get(NodeCreateSharePermissionType::class)); $registry->markPermissionTypeCompatibleWithPermissionPreset(NodeCreateSharePermissionType::class, EditSharePermissionPreset::class); diff --git a/apps/files/lib/Sharing/Property/NodeNicknameSharePropertyType.php b/apps/files/lib/Sharing/Property/NodeNicknameSharePropertyType.php new file mode 100644 index 0000000000000..cd69326731a60 --- /dev/null +++ b/apps/files/lib/Sharing/Property/NodeNicknameSharePropertyType.php @@ -0,0 +1,47 @@ +get(Application::APP_ID)->t('Always ask recipients for a nickname before uploading files.'); + } + + #[\Override] + public function getHint(IFactory $l10nFactory, Share $share): ?string { + return null; + } + + #[\Override] + public function getPriority(): int { + return 75; + } + + #[\Override] + public function isAdvanced(): bool { + return true; + } + + #[\Override] + public function isRequired(Share $share): bool { + return false; + } + + #[\Override] + public function getDefaultValue(Share $share): string { + return 'false'; + } +} From 930d76eeaf1a42963423f406d4d0881529499a2f Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 13 Oct 2026 10:19:36 +0200 Subject: [PATCH 17/23] fix(ShareByMailProvider): Set share status from DB Signed-off-by: provokateurin --- apps/sharebymail/lib/ShareByMailProvider.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sharebymail/lib/ShareByMailProvider.php b/apps/sharebymail/lib/ShareByMailProvider.php index 402fa17d39986..53dbf3428e21e 100644 --- a/apps/sharebymail/lib/ShareByMailProvider.php +++ b/apps/sharebymail/lib/ShareByMailProvider.php @@ -1047,7 +1047,8 @@ protected function createShareObject(array $data): IShare { ->setTarget($data['file_target']) ->setMailSend((bool)$data['mail_send']) ->setNote($data['note']) - ->setToken($data['token']); + ->setToken($data['token']) + ->setStatus((int)$data['accepted']); $shareTime = new \DateTime(); $shareTime->setTimestamp((int)$data['stime']); From d2f421767d10b8a53831c6946e3b2f64532c3f34 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 27 Oct 2026 09:40:10 +0100 Subject: [PATCH 18/23] fix(Share20): Fix setting target before and after the share is created in the provider Signed-off-by: provokateurin --- lib/private/Share20/Manager.php | 8 ++++---- tests/lib/Share20/ManagerTest.php | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/lib/private/Share20/Manager.php b/lib/private/Share20/Manager.php index ae765aea8becb..bfe2a7391fcc5 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -589,9 +589,9 @@ public function createShare(IShare $share): IShare { } } - if ($share->getTarget() === null) { - $target = $shareFolder . '/' . $share->getNode()->getName(); - $target = Filesystem::normalizePath($target); + $target = $share->getTarget(); + if (($target ?? '') === '') { + $target = Filesystem::normalizePath($shareFolder . '/' . $share->getNode()->getName()); $share->setTarget($target); } @@ -610,7 +610,7 @@ public function createShare(IShare $share): IShare { $share->setNode($oldShare->getNode()); // Reset the target if it is null for the new share - if ($share->getTarget() === '') { + if (($share->getTarget() ?? '') === '') { $share->setTarget($target); } } catch (AlreadySharedException $e) { diff --git a/tests/lib/Share20/ManagerTest.php b/tests/lib/Share20/ManagerTest.php index 805a59425d733..08bbc153371fd 100644 --- a/tests/lib/Share20/ManagerTest.php +++ b/tests/lib/Share20/ManagerTest.php @@ -3175,7 +3175,10 @@ public function testCreateShareUser(): void { $share->expects($this->once()) ->method('setShareOwner') ->with('shareOwner'); - $share->expects($this->once()) + $share->expects($this->exactly(2)) + ->method('getTarget') + ->willReturn(''); + $share->expects($this->exactly(2)) ->method('setTarget') ->with('/target'); @@ -3305,7 +3308,10 @@ public function testCreateShareGroup(): void { $share->expects($this->once()) ->method('setShareOwner') ->with('shareOwner'); - $share->expects($this->once()) + $share->expects($this->exactly(2)) + ->method('getTarget') + ->willReturn(''); + $share->expects($this->exactly(2)) ->method('setTarget') ->with('/target'); @@ -3691,7 +3697,10 @@ public function testCreateShareOfIncomingFederatedShare(): void { $share->expects($this->once()) ->method('setShareOwner') ->with('shareOwner'); - $share->expects($this->once()) + $share->expects($this->exactly(2)) + ->method('getTarget') + ->willReturn(''); + $share->expects($this->exactly(2)) ->method('setTarget') ->with('/target'); From ffdcc5991f86965f944192687a2ce29b9117efd1 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 27 Oct 2026 09:41:50 +0100 Subject: [PATCH 19/23] fix(FederatedShareProvider): Use existing share time and token if available Signed-off-by: provokateurin --- .../lib/FederatedShareProvider.php | 22 ++++++++++++++----- ...ederatedShareProviderReshareRemoteTest.php | 3 +++ .../tests/FederatedShareProviderTest.php | 3 +++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index 395e515d8d99d..8bfbc20df2f22 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -8,8 +8,10 @@ namespace OCA\FederatedFileSharing; +use DateTime; use OC\Share20\Exception\InvalidShare; use OC\Share20\Share; +use OCP\AppFramework\Utility\ITimeFactory; use OCP\Constants; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Federation\ICloudFederationProviderManager; @@ -61,6 +63,7 @@ public function __construct( private \OCP\GlobalScale\IConfig $gsConfig, private ICloudFederationProviderManager $cloudFederationProviderManager, private LoggerInterface $logger, + private ITimeFactory $timeFactory, ) { } @@ -140,7 +143,11 @@ public function create(IShare $share): IShare { if ($remoteShare) { $ownerCloudId = $this->cloudIdManager->getCloudId($remoteShare['owner'], $remoteShare['remote']); - $shareId = $this->addShareToDB($itemSource, $itemType, $shareWith, $sharedBy, $ownerCloudId->getId(), $permissions, 'tmp_token_' . time(), $shareType, $expirationDate); + $token = $share->getToken(); + if ($token === null || $token === '') { + $token = 'tmp_token_' . time(); + } + $shareId = $this->addShareToDB($itemSource, $itemType, $shareWith, $sharedBy, $ownerCloudId->getId(), $permissions, $token, $shareType, $expirationDate, $share->getShareTime() ?? \DateTime::createFromImmutable($this->timeFactory->now())); [$token, $remoteId] = $this->notifications->requestReShare( $remoteShare['refresh_token'], $remoteShare['remote_id'], @@ -175,7 +182,11 @@ public function create(IShare $share): IShare { * @throws \Exception */ protected function createFederatedShare(IShare $share): string { - $token = $this->tokenHandler->generateToken(); + $token = $share->getToken(); + if ($token === null || $token === '') { + $token = $this->tokenHandler->generateToken(); + } + $shareId = $this->addShareToDB( $share->getNodeId(), $share->getNodeType(), @@ -185,7 +196,8 @@ protected function createFederatedShare(IShare $share): string { $share->getPermissions(), $token, $share->getShareType(), - $share->getExpirationDate() + $share->getExpirationDate(), + $share->getShareTime() ?? \DateTime::createFromImmutable($this->timeFactory->now()), ); $failure = false; @@ -266,7 +278,7 @@ protected function getShareFromExternalShareTable(string $owner, string $target) * @param int $shareType * @param \DateTime $expirationDate */ - private function addShareToDB($itemSource, $itemType, $shareWith, $sharedBy, $uidOwner, $permissions, $token, $shareType, $expirationDate): string { + private function addShareToDB($itemSource, $itemType, $shareWith, $sharedBy, $uidOwner, $permissions, $token, $shareType, $expirationDate, DateTime $shareTime): string { $qb = $this->dbConnection->getQueryBuilder(); $qb->insert('share') ->setValue('share_type', $qb->createNamedParameter($shareType)) @@ -279,7 +291,7 @@ private function addShareToDB($itemSource, $itemType, $shareWith, $sharedBy, $ui ->setValue('permissions', $qb->createNamedParameter($permissions)) ->setValue('expiration', $qb->createNamedParameter($expirationDate, IQueryBuilder::PARAM_DATETIME_MUTABLE)) ->setValue('token', $qb->createNamedParameter($token)) - ->setValue('stime', $qb->createNamedParameter(time())); + ->setValue('stime', $qb->createNamedParameter($shareTime->getTimestamp())); /* * Added to fix https://github.com/owncloud/core/issues/22215 diff --git a/apps/federatedfilesharing/tests/FederatedShareProviderReshareRemoteTest.php b/apps/federatedfilesharing/tests/FederatedShareProviderReshareRemoteTest.php index 03ac356813ca6..f4c3cc9866362 100644 --- a/apps/federatedfilesharing/tests/FederatedShareProviderReshareRemoteTest.php +++ b/apps/federatedfilesharing/tests/FederatedShareProviderReshareRemoteTest.php @@ -15,6 +15,7 @@ use OCA\FederatedFileSharing\FederatedShareProvider; use OCA\FederatedFileSharing\Notifications; use OCA\FederatedFileSharing\TokenHandler; +use OCP\AppFramework\Utility\ITimeFactory; use OCP\Constants; use OCP\DB\IResult; use OCP\DB\QueryBuilder\IExpressionBuilder; @@ -29,6 +30,7 @@ use OCP\IDBConnection; use OCP\IL10N; use OCP\IUserManager; +use OCP\Server; use OCP\Share\IShare; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; @@ -77,6 +79,7 @@ protected function setUp(): void { $this->gsConfig, $this->cloudFederationProviderManager, $this->logger, + Server::get(ITimeFactory::class), ); } diff --git a/apps/federatedfilesharing/tests/FederatedShareProviderTest.php b/apps/federatedfilesharing/tests/FederatedShareProviderTest.php index d83ecde0e9725..55d979a08b8dd 100644 --- a/apps/federatedfilesharing/tests/FederatedShareProviderTest.php +++ b/apps/federatedfilesharing/tests/FederatedShareProviderTest.php @@ -15,6 +15,7 @@ use OCA\FederatedFileSharing\FederatedShareProvider; use OCA\FederatedFileSharing\Notifications; use OCA\FederatedFileSharing\TokenHandler; +use OCP\AppFramework\Utility\ITimeFactory; use OCP\Constants; use OCP\Contacts\IManager as IContactsManager; use OCP\DB\QueryBuilder\IQueryBuilder; @@ -104,6 +105,7 @@ protected function setUp(): void { $this->gsConfig, $this->cloudFederationProviderManager, $this->logger, + Server::get(ITimeFactory::class), ); $this->shareManager = Server::get(IManager::class); @@ -482,6 +484,7 @@ public function testUpdate(string $owner, string $sharedBy, ?\DateTime $expirati $this->gsConfig, $this->cloudFederationProviderManager, $this->logger, + Server::get(ITimeFactory::class), ] ) ->onlyMethods(['sendPermissionUpdate']) From 2a5e2386ac3808c3a6a63219bac11ff4e1251fdc Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 27 Oct 2026 10:18:08 +0100 Subject: [PATCH 20/23] refactor(Share): Allow checking effective enabled permissions for explicit recipients Signed-off-by: provokateurin --- lib/private/Sharing/SharingManager.php | 12 +++---- lib/unstable/Sharing/Share.php | 49 ++++++++++++++++---------- tests/lib/Sharing/ShareTest.php | 2 +- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/lib/private/Sharing/SharingManager.php b/lib/private/Sharing/SharingManager.php index 4725f8912ab7f..e062f7f909103 100644 --- a/lib/private/Sharing/SharingManager.php +++ b/lib/private/Sharing/SharingManager.php @@ -736,11 +736,8 @@ public function updateShareRecipientPermission(ShareAccessContext $accessContext $this->validateReshareOperation($accessContext, $share, $recipient); } - if ($permission->enabled) { - $enabledPermissions = $share->getEffectiveEnabledPermissions($accessContext); - if (!isset($enabledPermissions[$permission->class])) { - throw new ShareOperationForbiddenException(); - } + if ($permission->enabled && !in_array($permission->class, $share->getEffectiveEnabledPermissions($accessContext), true)) { + throw new ShareOperationForbiddenException(); } $this->backend->updateShareRecipientPermission($share->id, $recipient, $permission); @@ -934,7 +931,7 @@ private function validateShareEditPermissions(ShareAccessContext $accessContext, * @throws ShareOperationForbiddenException */ private function validatePermission(ShareAccessContext $accessContext, Share $share, string $permissionTypeClass): void { - if (!isset($share->getEffectiveEnabledPermissions($accessContext)[$permissionTypeClass])) { + if (!in_array($permissionTypeClass, $share->getEffectiveEnabledPermissions($accessContext), true)) { throw new ShareOperationForbiddenException(); } } @@ -965,7 +962,8 @@ private function validateReshareOperation(ShareAccessContext $accessContext, Sha */ private function validateInteraction(ShareAccessContext $accessContext, Share $share): void { $action = new ShareAction( - null, array_values(array_map(static fn (SharePermission $permission): string => $permission->class, $share->getEffectiveEnabledPermissions($accessContext))) + null, + $share->getEffectiveEnabledPermissions($accessContext), ); if ($share->owner->instance !== null) { diff --git a/lib/unstable/Sharing/Share.php b/lib/unstable/Sharing/Share.php index 12e1bb5d148d4..9643ff5637347 100644 --- a/lib/unstable/Sharing/Share.php +++ b/lib/unstable/Sharing/Share.php @@ -157,7 +157,7 @@ final class Share { /** @var array> $recipientsCache */ private array $recipientsCache = []; - /** @var array, SharePermission>> $enabledPermissionsCache */ + /** @var array>> $enabledPermissionsCache */ private array $enabledPermissionsCache = []; /** @@ -225,7 +225,7 @@ public function getEffectiveRecipients(ShareAccessContext $accessContext): array } /** - * @return array, SharePermission> + * @return list> * @experimental 35.0.0 */ public function getEffectiveEnabledPermissions(ShareAccessContext $accessContext): array { @@ -234,29 +234,42 @@ public function getEffectiveEnabledPermissions(ShareAccessContext $accessContext return $enabledPermissions; } + if ($accessContext->overrideChecks || $this->owner->isCurrentUser($accessContext)) { + $enabledPermissions = array_keys(array_filter($this->permissions, static fn (SharePermission $permission): bool => $permission->enabled)); + } else { + $enabledPermissions = $this->getEffectiveEnabledPermissionsForRecipients($this->getEffectiveRecipients($accessContext)); + } + + return $this->enabledPermissionsCache[$hash] = $enabledPermissions; + } + + /** + * @param list $recipients + * @return list> + * @experimental 35.0.0 + */ + public function getEffectiveEnabledPermissionsForRecipients(array $recipients): array { $enabledPermissions = array_filter($this->permissions, static fn (SharePermission $permission): bool => $permission->enabled); - if (!$accessContext->overrideChecks && !$this->owner->isCurrentUser($accessContext)) { - /** @var array, SharePermission> $recipientsEnabledPermissions */ - $recipientsEnabledPermissions = []; - foreach ($this->getEffectiveRecipients($accessContext) as $recipient) { - foreach ($recipient->permissions as $permission) { - // If any recipient has the permission enabled, we grant it. - if (!isset($recipientsEnabledPermissions[$permission->class]) || ($permission->enabled && !$recipientsEnabledPermissions[$permission->class]->enabled)) { - $recipientsEnabledPermissions[$permission->class] = $permission; - } + /** @var array, SharePermission> $recipientsEnabledPermissions */ + $recipientsEnabledPermissions = []; + foreach ($recipients as $recipient) { + foreach ($recipient->permissions as $permission) { + // If any recipient has the permission enabled, we grant it. + if (!isset($recipientsEnabledPermissions[$permission->class]) || ($permission->enabled && !$recipientsEnabledPermissions[$permission->class]->enabled)) { + $recipientsEnabledPermissions[$permission->class] = $permission; } } + } - foreach ($recipientsEnabledPermissions as $permission) { - // If the permission was disabled by any recipient and not enabled by another, we deny it. - if (!$permission->enabled) { - unset($enabledPermissions[$permission->class]); - } + foreach ($recipientsEnabledPermissions as $permission) { + // If the permission was disabled by any recipient and not enabled by another, we deny it. + if (!$permission->enabled) { + unset($enabledPermissions[$permission->class]); } } - return $this->enabledPermissionsCache[$hash] = $enabledPermissions; + return array_keys($enabledPermissions); } /** @@ -284,7 +297,7 @@ public function format(ISharingRegistry $registry, IFactory $l10nFactory, IURLGe $selectedPermissionPresetClass = null; - $enabledPermissionTypeClasses = array_values(array_map(static fn (SharePermission $permission): string => $permission->class, $this->getEffectiveEnabledPermissions($accessContext))); + $enabledPermissionTypeClasses = $this->getEffectiveEnabledPermissions($accessContext); sort($enabledPermissionTypeClasses); $requiredPermissionTypeClasses = []; diff --git a/tests/lib/Sharing/ShareTest.php b/tests/lib/Sharing/ShareTest.php index 817a525a719f2..a92e48af84eb1 100644 --- a/tests/lib/Sharing/ShareTest.php +++ b/tests/lib/Sharing/ShareTest.php @@ -203,7 +203,7 @@ public function testGetEffectiveEnabledPermissions(array $recipients, array $per $this->assertEquals( $enabled - ? [TestSharePermissionType1::class => new SharePermission(TestSharePermissionType1::class, true)] + ? [TestSharePermissionType1::class] : [], $share->getEffectiveEnabledPermissions(new ShareAccessContext($this->user1)), ); From f2bf3ec898c757e4abbe0810777629e6c4bc774f Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 23 Sep 2026 13:51:15 +0200 Subject: [PATCH 21/23] feat(Sharing): Implement bi-directional syncing with legacy sharing Signed-off-by: provokateurin --- apps/files/lib/AppInfo/Application.php | 14 +- .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + apps/sharing/lib/AppInfo/Application.php | 19 + .../Version2000Date20261101154320.php | 39 + core/AppInfo/Application.php | 12 +- lib/composer/composer/autoload_classmap.php | 3 + lib/composer/composer/autoload_static.php | 3 + lib/private/Sharing/LegacyMapper.php | 163 ++ lib/private/Sharing/LegacyMapping.php | 42 + lib/private/Sharing/SharingBackend.php | 2 +- lib/private/Sharing/SharingLegacySync.php | 1122 +++++++++ lib/private/Sharing/SharingManager.php | 5 + psalm-strict.xml | 2 + tests/lib/Sharing/SharingLegacySyncTest.php | 2121 +++++++++++++++++ 15 files changed, 3540 insertions(+), 9 deletions(-) create mode 100644 apps/sharing/lib/Migration/Version2000Date20261101154320.php create mode 100644 lib/private/Sharing/LegacyMapper.php create mode 100644 lib/private/Sharing/LegacyMapping.php create mode 100644 lib/private/Sharing/SharingLegacySync.php create mode 100644 tests/lib/Sharing/SharingLegacySyncTest.php diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php index c98eb8d65681c..026c3ac0c9f99 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -116,6 +116,16 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(RestrictInteractionEvent::class, RestrictInteractionListener::class); + $context->registerEventListener(SharesUpdatedEvent::class, SharesUpdatedListener::class); + + $this->registerSharing(); + + $context->registerEventListener(NodeDeletedEvent::class, NodeShareSourceType::class); + $context->registerEventListener(MoveToTrashEvent::class, NodeShareSourceType::class); + $context->registerEventListener(SharesUpdatedEvent::class, SharesUpdatedListener::class); + } + + private function registerSharing(): void { $registry = Server::get(ISharingRegistry::class); $registry->registerSourceType(Server::get(NodeShareSourceType::class)); @@ -148,10 +158,6 @@ public function register(IRegistrationContext $context): void { $registry->registerPermissionType(NodeShareSourceType::class, Server::get(NodeDownloadSharePermissionType::class)); $registry->markPermissionTypeCompatibleWithPermissionPreset(NodeDownloadSharePermissionType::class, ViewSharePermissionPreset::class); $registry->markPermissionTypeCompatibleWithPermissionPreset(NodeDownloadSharePermissionType::class, EditSharePermissionPreset::class); - - $context->registerEventListener(NodeDeletedEvent::class, NodeShareSourceType::class); - $context->registerEventListener(MoveToTrashEvent::class, NodeShareSourceType::class); - $context->registerEventListener(SharesUpdatedEvent::class, SharesUpdatedListener::class); } #[\Override] diff --git a/apps/sharing/composer/composer/autoload_classmap.php b/apps/sharing/composer/composer/autoload_classmap.php index 0e7fe075d6063..04f8810db7bfe 100644 --- a/apps/sharing/composer/composer/autoload_classmap.php +++ b/apps/sharing/composer/composer/autoload_classmap.php @@ -34,5 +34,6 @@ 'OCA\\Sharing\\Migration\\Version1000Date20260826122228' => $baseDir . '/../lib/Migration/Version1000Date20260826122228.php', 'OCA\\Sharing\\Migration\\Version2000Date20261013142538' => $baseDir . '/../lib/Migration/Version2000Date20261013142538.php', 'OCA\\Sharing\\Migration\\Version2000Date20261014182648' => $baseDir . '/../lib/Migration/Version2000Date20261014182648.php', + 'OCA\\Sharing\\Migration\\Version2000Date20261101154320' => $baseDir . '/../lib/Migration/Version2000Date20261101154320.php', 'OCA\\Sharing\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php', ); diff --git a/apps/sharing/composer/composer/autoload_static.php b/apps/sharing/composer/composer/autoload_static.php index 79b905c629927..2152bc1a34902 100644 --- a/apps/sharing/composer/composer/autoload_static.php +++ b/apps/sharing/composer/composer/autoload_static.php @@ -49,6 +49,7 @@ class ComposerStaticInitSharing 'OCA\\Sharing\\Migration\\Version1000Date20260826122228' => __DIR__ . '/..' . '/../lib/Migration/Version1000Date20260826122228.php', 'OCA\\Sharing\\Migration\\Version2000Date20261013142538' => __DIR__ . '/..' . '/../lib/Migration/Version2000Date20261013142538.php', 'OCA\\Sharing\\Migration\\Version2000Date20261014182648' => __DIR__ . '/..' . '/../lib/Migration/Version2000Date20261014182648.php', + 'OCA\\Sharing\\Migration\\Version2000Date20261101154320' => __DIR__ . '/..' . '/../lib/Migration/Version2000Date20261101154320.php', 'OCA\\Sharing\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php', ); diff --git a/apps/sharing/lib/AppInfo/Application.php b/apps/sharing/lib/AppInfo/Application.php index f7061efe270e9..816a03da619d7 100644 --- a/apps/sharing/lib/AppInfo/Application.php +++ b/apps/sharing/lib/AppInfo/Application.php @@ -9,6 +9,9 @@ namespace OCA\Sharing\AppInfo; +use NCU\Sharing\Event\SharesDeletedEvent; +use NCU\Sharing\Event\SharesUpdatedEvent; +use OC\Sharing\SharingLegacySync; use OC\Sharing\SharingManager; use OCA\Sharing\Capabilities; use OCA\Sharing\Middleware\ShareApiEnabledMiddleware; @@ -16,6 +19,13 @@ use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Share\Events\ShareAcceptedEvent; +use OCP\Share\Events\ShareCreatedEvent; +use OCP\Share\Events\ShareDeletedEvent; +use OCP\Share\Events\ShareDeletedFromSelfEvent; +use OCP\Share\Events\ShareMovedEvent; +use OCP\Share\Events\ShareRestoredEvent; +use OCP\Share\Events\ShareUpdatedEvent; use OCP\User\Events\BeforeUserDeletedEvent; final class Application extends App implements IBootstrap { @@ -31,6 +41,15 @@ public function register(IRegistrationContext $context): void { $context->registerMiddleware(ShareApiEnabledMiddleware::class); $context->registerEventListener(BeforeUserDeletedEvent::class, SharingManager::class); + $context->registerEventListener(ShareAcceptedEvent::class, SharingLegacySync::class); + $context->registerEventListener(ShareCreatedEvent::class, SharingLegacySync::class); + $context->registerEventListener(ShareDeletedEvent::class, SharingLegacySync::class); + $context->registerEventListener(ShareDeletedFromSelfEvent::class, SharingLegacySync::class); + $context->registerEventListener(ShareMovedEvent::class, SharingLegacySync::class); + $context->registerEventListener(ShareRestoredEvent::class, SharingLegacySync::class); + $context->registerEventListener(ShareUpdatedEvent::class, SharingLegacySync::class); + $context->registerEventListener(SharesUpdatedEvent::class, SharingLegacySync::class); + $context->registerEventListener(SharesDeletedEvent::class, SharingLegacySync::class); } #[\Override] diff --git a/apps/sharing/lib/Migration/Version2000Date20261101154320.php b/apps/sharing/lib/Migration/Version2000Date20261101154320.php new file mode 100644 index 0000000000000..4f330c65e0a3d --- /dev/null +++ b/apps/sharing/lib/Migration/Version2000Date20261101154320.php @@ -0,0 +1,39 @@ +getTable('sharing_classmap'); + + $legacyMappingTable = $schema->createTable('sharing_share_legacy_mapping'); + $legacyMappingTable->addColumn('id', Types::BIGINT); + $legacyMappingTable->addColumn('legacy_provider_class_id', Types::INTEGER); + $legacyMappingTable->addColumn('legacy_id', Types::INTEGER); + $legacyMappingTable->addColumn('last_updated', Types::BIGINT); + $legacyMappingTable->addColumn('recipient_secrets', Types::TEXT); + $legacyMappingTable->addColumn('recipient_value', Types::STRING, ['length' => 255]); + $legacyMappingTable->setPrimaryKey(['legacy_provider_class_id', 'legacy_id']); + $legacyMappingTable->addIndex(['id']); + $legacyMappingTable->addForeignKeyConstraint($mappingTable->getName(), ['legacy_provider_class_id'], ['class_id']); + + return $schema; + } +} diff --git a/core/AppInfo/Application.php b/core/AppInfo/Application.php index 32ee1041c24bb..f5c473bfe80ce 100644 --- a/core/AppInfo/Application.php +++ b/core/AppInfo/Application.php @@ -131,6 +131,14 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UserDeletedEvent::class, UserDeletedDirectEditingTokenCleanupListener::class); $context->registerEventListener(UserChangedEvent::class, UserDisabledDirectEditingTokenCleanupListener::class); + $this->registerSharing(); + + $context->registerEventListener(BeforeGroupDeletedEvent::class, GroupShareRecipientType::class); + $context->registerEventListener(DestroyingCircleEvent::class, TeamShareRecipientType::class); + $context->registerEventListener(BeforeUserDeletedEvent::class, UserShareRecipientType::class); + } + + private function registerSharing(): void { $registry = Server::get(ISharingRegistry::class); $registry->registerRecipientType(Server::get(EmailShareRecipientType::class)); @@ -168,10 +176,6 @@ public function register(IRegistrationContext $context): void { if (!Server::get(IAppConfig::class)->getValueBool('files_sharing', 'shareapi_exclude_reshare_from_edit')) { $registry->markPermissionTypeCompatibleWithPermissionPreset(ReshareSharePermissionType::class, EditSharePermissionPreset::class); } - - $context->registerEventListener(BeforeGroupDeletedEvent::class, GroupShareRecipientType::class); - $context->registerEventListener(DestroyingCircleEvent::class, TeamShareRecipientType::class); - $context->registerEventListener(BeforeUserDeletedEvent::class, UserShareRecipientType::class); } #[\Override] diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 26682dcff3549..462960b3ac911 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -2409,7 +2409,10 @@ 'OC\\Sharing\\AbstractStringMapper' => $baseDir . '/lib/private/Sharing/AbstractStringMapper.php', 'OC\\Sharing\\ClassMapper' => $baseDir . '/lib/private/Sharing/ClassMapper.php', 'OC\\Sharing\\InstanceMapper' => $baseDir . '/lib/private/Sharing/InstanceMapper.php', + 'OC\\Sharing\\LegacyMapper' => $baseDir . '/lib/private/Sharing/LegacyMapper.php', + 'OC\\Sharing\\LegacyMapping' => $baseDir . '/lib/private/Sharing/LegacyMapping.php', 'OC\\Sharing\\SharingBackend' => $baseDir . '/lib/private/Sharing/SharingBackend.php', + 'OC\\Sharing\\SharingLegacySync' => $baseDir . '/lib/private/Sharing/SharingLegacySync.php', 'OC\\Sharing\\SharingManager' => $baseDir . '/lib/private/Sharing/SharingManager.php', 'OC\\Sharing\\SharingRegistry' => $baseDir . '/lib/private/Sharing/SharingRegistry.php', 'OC\\Snowflake\\APCuSequence' => $baseDir . '/lib/private/Snowflake/APCuSequence.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 003a89df24699..98b931e16fdd2 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -2450,7 +2450,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Sharing\\AbstractStringMapper' => __DIR__ . '/../../..' . '/lib/private/Sharing/AbstractStringMapper.php', 'OC\\Sharing\\ClassMapper' => __DIR__ . '/../../..' . '/lib/private/Sharing/ClassMapper.php', 'OC\\Sharing\\InstanceMapper' => __DIR__ . '/../../..' . '/lib/private/Sharing/InstanceMapper.php', + 'OC\\Sharing\\LegacyMapper' => __DIR__ . '/../../..' . '/lib/private/Sharing/LegacyMapper.php', + 'OC\\Sharing\\LegacyMapping' => __DIR__ . '/../../..' . '/lib/private/Sharing/LegacyMapping.php', 'OC\\Sharing\\SharingBackend' => __DIR__ . '/../../..' . '/lib/private/Sharing/SharingBackend.php', + 'OC\\Sharing\\SharingLegacySync' => __DIR__ . '/../../..' . '/lib/private/Sharing/SharingLegacySync.php', 'OC\\Sharing\\SharingManager' => __DIR__ . '/../../..' . '/lib/private/Sharing/SharingManager.php', 'OC\\Sharing\\SharingRegistry' => __DIR__ . '/../../..' . '/lib/private/Sharing/SharingRegistry.php', 'OC\\Snowflake\\APCuSequence' => __DIR__ . '/../../..' . '/lib/private/Snowflake/APCuSequence.php', diff --git a/lib/private/Sharing/LegacyMapper.php b/lib/private/Sharing/LegacyMapper.php new file mode 100644 index 0000000000000..61f7cbd7f326d --- /dev/null +++ b/lib/private/Sharing/LegacyMapper.php @@ -0,0 +1,163 @@ + $legacyProviderClass + * @param non-empty-list $recipientSecrets + */ + public function createLegacyMapping(string $id, string $legacyProviderClass, int $legacyId, DateTimeImmutable $lastUpdated, array $recipientSecrets, string $recipientValue): LegacyMapping { + $qb = $this->dbConnection->getQueryBuilder(); + $qb + ->insert('sharing_share_legacy_mapping') + ->values([ + 'id' => $qb->createNamedParameter($id), + 'legacy_provider_class_id' => $qb->createNamedParameter($this->classMapper->getClassId($legacyProviderClass), IQueryBuilder::PARAM_INT), + 'legacy_id' => $qb->createNamedParameter($legacyId, IQueryBuilder::PARAM_INT), + 'last_updated' => $qb->createNamedParameter(SharingManager::timeToMs($lastUpdated)), + 'recipient_secrets' => $qb->createNamedParameter(json_encode($recipientSecrets)), + 'recipient_value' => $qb->createNamedParameter($recipientValue), + ]) + ->executeStatement(); + + return new LegacyMapping( + $id, + $legacyProviderClass, + $legacyId, + $lastUpdated, + $recipientSecrets, + $recipientValue, + ); + } + + /** + * @param class-string $legacyProviderClass + */ + public function getLegacyMappingByLegacyProviderClassAndId(string $legacyProviderClass, int $legacyId): ?LegacyMapping { + $qb = $this->dbConnection->getQueryBuilder(); + $result = $qb + ->select('id', 'last_updated', 'recipient_secrets', 'recipient_value') + ->from('sharing_share_legacy_mapping') + ->where($qb->expr()->eq('legacy_provider_class_id', $qb->createNamedParameter($this->classMapper->getClassId($legacyProviderClass), IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('legacy_id', $qb->createNamedParameter($legacyId, IQueryBuilder::PARAM_INT))) + ->executeQuery(); + + /** @var array{id: int, last_updated: int, recipient_secrets: non-empty-string, recipient_value: string}|false $row */ + $row = $result->fetch(); + if ($row === false) { + return null; + } + + return new LegacyMapping( + (string)$row['id'], + $legacyProviderClass, + $legacyId, + SharingBackend::parseTimestamp((string)$row['last_updated']), + $this->decodeRecipientSecrets($row['recipient_secrets']), + $row['recipient_value'], + ); + } + + /** + * @param non-empty-string $id + * @return list + */ + public function getLegacyMappings(string $id): array { + $legacyMappings = []; + + $qb = $this->dbConnection->getQueryBuilder(); + $result = $qb + ->select('id', 'legacy_provider_class_id', 'legacy_id', 'last_updated', 'recipient_secrets', 'recipient_value') + ->from('sharing_share_legacy_mapping') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))) + ->executeQuery(); + + /** @var list $rows */ + $rows = $result->fetchAllAssociative(); + foreach ($rows as $row) { + /** @var class-string $legacyProviderClass */ + $legacyProviderClass = $this->classMapper->getClassName((int)$row['legacy_provider_class_id']); + $legacyMappings[] = new LegacyMapping( + (string)$row['id'], + $legacyProviderClass, + (int)$row['legacy_id'], + SharingBackend::parseTimestamp((string)$row['last_updated']), + $this->decodeRecipientSecrets($row['recipient_secrets']), + $row['recipient_value'], + ); + } + + return $legacyMappings; + } + + /** + * @param non-empty-string $id + */ + public function deleteLegacyMappings(string $id): void { + $qb = $this->dbConnection->getQueryBuilder(); + $qb + ->delete('sharing_share_legacy_mapping') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))) + ->executeStatement(); + } + + /** + * @param class-string $legacyProviderClass + */ + public function deleteLegacyMapping(string $legacyProviderClass, int $legacyId): void { + $qb = $this->dbConnection->getQueryBuilder(); + $qb + ->delete('sharing_share_legacy_mapping') + ->where($qb->expr()->eq('legacy_provider_class_id', $qb->createNamedParameter($this->classMapper->getClassId($legacyProviderClass), IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('legacy_id', $qb->createNamedParameter($legacyId, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + + public function updateLegacyMapping(LegacyMapping $legacyMapping): void { + $qb = $this->dbConnection->getQueryBuilder(); + $qb + ->update('sharing_share_legacy_mapping') + ->set('last_updated', $qb->createNamedParameter(SharingManager::timeToMs($legacyMapping->lastUpdated))) + ->set('recipient_secrets', $qb->createNamedParameter(json_encode($legacyMapping->recipientSecrets))) + ->set('recipient_value', $qb->createNamedParameter($legacyMapping->recipientValue)) + ->where($qb->expr()->eq('legacy_provider_class_id', $qb->createNamedParameter($this->classMapper->getClassId($legacyMapping->legacyProviderClass), IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('legacy_id', $qb->createNamedParameter($legacyMapping->legacyId))) + ->executeStatement(); + } + + /** + * @psalm-suppress MixedReturnTypeCoercion Psalm doesn't understand the string checks + * @param non-empty-string $raw + * @return non-empty-list + */ + private function decodeRecipientSecrets(string $raw): array { + $data = json_decode($raw); + if (!is_array($data) || !array_is_list($data) || $data === [] || array_any($data, static fn (mixed $value): bool => !is_string($value) || $value === '')) { + throw new RuntimeException('The recipient secrets must be a non-empty list of non-empty strings.'); + } + + return $data; + } +} diff --git a/lib/private/Sharing/LegacyMapping.php b/lib/private/Sharing/LegacyMapping.php new file mode 100644 index 0000000000000..aa00856587eed --- /dev/null +++ b/lib/private/Sharing/LegacyMapping.php @@ -0,0 +1,42 @@ + $legacyProviderClass */ + public string $legacyProviderClass, + public int $legacyId, + public DateTimeImmutable $lastUpdated, + /** + * A single legacy share can correspond to multiple share recipients, so we have to save the secret for all of them. + * @var non-empty-list $recipientSecrets + */ + public array $recipientSecrets, + public string $recipientValue, + ) { + } + + /** + * @throws ShareNotFound + */ + public function getLegacyShare(): IShare { + // Not cached on purpose, so we always get the current state of the share. + return Server::get($this->legacyProviderClass)->getShareById((string)$this->legacyId); + } +} diff --git a/lib/private/Sharing/SharingBackend.php b/lib/private/Sharing/SharingBackend.php index 5db5d59a8c147..82458adadcf3a 100644 --- a/lib/private/Sharing/SharingBackend.php +++ b/lib/private/Sharing/SharingBackend.php @@ -1423,7 +1423,7 @@ public function getRecommendedRecipients( }, $rows))); } - private static function parseTimestamp(string $timestampMs): \DateTimeImmutable { + public static function parseTimestamp(string $timestampMs): \DateTimeImmutable { if (method_exists(\DateTimeImmutable::class, 'createFromTimestamp')) { // with php 8.3 the method doesn't exist and psalm doesn't know the return type /** @psalm-suppress MixedReturnStatement */ diff --git a/lib/private/Sharing/SharingLegacySync.php b/lib/private/Sharing/SharingLegacySync.php new file mode 100644 index 0000000000000..d92a19f186508 --- /dev/null +++ b/lib/private/Sharing/SharingLegacySync.php @@ -0,0 +1,1122 @@ + + */ +final class SharingLegacySync implements IEventListener { + private ?ISharingManager $sharingManager = null; + + private ?SourceNodeTargetManager $sourceNodeTargetManager = null; + + /** @var array */ + private array $sharesInSync = []; + + // Only used for testing! + private bool $ignoreAllEvents = false; + + private bool $ignoreNewLegacyShare = false; + + public function __construct( + private readonly IManager $legacySharingManager, + private readonly ISharingBackend $backend, + private readonly IDBConnection $dbConnection, + private readonly ISnowflakeGenerator $snowflakeGenerator, + private readonly ICloudIdManager $cloudIdManager, + private readonly LegacyMapper $legacyMapper, + private readonly ISharingRegistry $sharingRegistry, + private readonly IConfig $config, + private readonly IMimeTypeLoader $mimeTypeLoader, + private readonly IRootFolder $rootFolder, + private readonly IProviderFactory $legacyShareProviderFactory, + ) { + } + + private function getSharingManager(): ISharingManager { + // Can't inject in constructor, as it leads to an infinite loop + return $this->sharingManager ??= Server::get(ISharingManager::class); + } + + private function getSourceNodeTargetManager(): SourceNodeTargetManager { + // Class is not resolved when DI tries to construct this class. + return $this->sourceNodeTargetManager ??= Server::get(SourceNodeTargetManager::class); + } + + private function isValidationEnabled(): bool { + return $this->config->getSystemValueBool('unified_sharing.legacy_sync.validation.enable'); + } + + private function isPublicLegacyShare(IShare $legacyShare): bool { + return $legacyShare->getShareType() === IShare::TYPE_LINK || $legacyShare->getShareType() === IShare::TYPE_EMAIL; + } + + private function isRemoteLegacyShare(IShare $legacyShare): bool { + return $legacyShare->getShareType() === IShare::TYPE_REMOTE || $legacyShare->getShareType() === IShare::TYPE_REMOTE_GROUP; + } + + #[\Override] + public function handle(Event $event): void { + if ($this->ignoreAllEvents) { + return; + } + + // TODO: Disable syncing if kill switch is enabled and truncate the unified sharing table to avoid the two systems getting out of sync. + // TODO: Sync shares might trigger event listeners that modify the target shares (e.g. auto accept), but we ignore them here. We need to detect this case and then sync back the changes to the source shares. Let's pray no infinite loop get's started by this. + + if ( + $event instanceof ShareAcceptedEvent + || $event instanceof ShareCreatedEvent + || $event instanceof ShareDeletedEvent + || $event instanceof ShareDeletedFromSelfEvent + || $event instanceof ShareMovedEvent + || $event instanceof ShareRestoredEvent + || $event instanceof ShareUpdatedEvent + ) { + $legacyShare = $event->getShare(); + $legacyProviderClass = $this->legacyShareProviderFactory->getProvider($legacyShare->getProviderId())::class; + $legacyId = (int)$legacyShare->getId(); + $legacyMapping = $this->legacyMapper->getLegacyMappingByLegacyProviderClassAndId($legacyProviderClass, $legacyId); + + if ($event instanceof ShareDeletedEvent) { + // If the legacy share was not mapped to a share, we can ignore it. + if ($legacyMapping instanceof LegacyMapping) { + $this->wrapSync($legacyMapping->id, function () use ($legacyMapping): void { + $legacyMappings = $this->legacyMapper->getLegacyMappings($legacyMapping->id); + // If this is the last mapping, delete the entire share. + // If there are more mappings, only delete that mapping and then sync. + if (count($legacyMappings) === 1) { + try { + $this->backend->deleteShare($legacyMapping->id); + } catch (ShareNotFoundException) { + // If the owner is deleted, the share was already deleted by the user recipient type through the manager, but we still receive this event from the legacy sharing manager. + } + + $this->legacyMapper->deleteLegacyMappings($legacyMapping->id); + } else { + $this->legacyMapper->deleteLegacyMapping($legacyMapping->legacyProviderClass, $legacyMapping->legacyId); + /** @var non-empty-list $legacyMappings The list can't be empty, because there is at least one more legacy mapping than $legacyMapping */ + $legacyMappings = $this->legacyMapper->getLegacyMappings($legacyMapping->id); + $this->syncLegacySharesToShare($legacyMapping->id, $legacyMappings); + } + }); + } + } else { + $id = null; + if ($legacyMapping instanceof LegacyMapping) { + $id = $legacyMapping->id; + $this->wrapSync($id, function () use ($id): void { + /** @var non-empty-list $legacyMappings The list can't be empty, because $legacyMapping is not null */ + $legacyMappings = $this->legacyMapper->getLegacyMappings($id); + $this->syncLegacySharesToShare($id, $legacyMappings); + }); + } elseif (!$this->ignoreNewLegacyShare) { + $id = $this->snowflakeGenerator->nextId(DateTimeImmutable::createFromMutable($legacyShare->getShareTime())); + $this->wrapSync($id, function () use ($id, $legacyProviderClass, $legacyId, $legacyShare): void { + $secrets = []; + $value = null; + if ($this->isPublicLegacyShare($legacyShare) || $this->isRemoteLegacyShare($legacyShare)) { + $token = $legacyShare->getToken(); + if ($token === '') { + throw new RuntimeException('Token must not be an empty string.'); + } + + $secrets = [$token]; + } + + if ($legacyShare->getShareType() === IShare::TYPE_LINK) { + $value = $this->getSharingManager()->generateSecret(); + } + + if ($legacyShare->getAttributes()?->getAttribute('fileRequest', 'enabled') === true) { + $emails = $legacyShare->getAttributes()?->getAttribute('shareWith', 'emails') ?? []; + if (!is_array($emails) || !array_is_list($emails) || array_any($emails, static fn (mixed $value): bool => !is_string($value) || $value === '')) { + throw new RuntimeException('The emails must be a list of non-empty strings.'); + } + + $token = $legacyShare->getToken(); + if ($token === '') { + throw new RuntimeException('Token must not be an empty string.'); + } + + $secrets = [$token]; + $emailCount = count($emails); + for ($i = 0; $i < $emailCount; ++$i) { + $secrets[] = $this->getSharingManager()->generateSecret(); + } + + $value = $this->getSharingManager()->generateSecret(); + } + + if ($secrets === []) { + $secrets = [$this->getSharingManager()->generateSecret()]; + } + + $value ??= $this->splitLegacySharedWith($legacyShare)['value']; + + $legacyMapping = $this->legacyMapper->createLegacyMapping( + $id, + $legacyProviderClass, + $legacyId, + new DateTimeImmutable(), + $secrets, + $value, + ); + $this->syncLegacySharesToShare($id, [$legacyMapping]); + }); + } + + if ($id !== null && $this->isValidationEnabled()) { + $this->wrapSync($id, function () use ($id): void { + $share = $this->getSharingManager()->getShare(new ShareAccessContext(overrideChecks: true), $id); + $this->syncShareToLegacyShares($share); + }); + + if (!$event instanceof ShareDeletedFromSelfEvent) { + $event->setShare($this->legacySharingManager->getShareById($legacyShare->getFullId(), onlyValid: false)); + } + } + } + + return; + } + + if ($event instanceof SharesDeletedEvent) { + foreach ($event->shareIds as $id) { + $this->wrapSync($id, function () use ($id): void { + $legacyMappings = $this->legacyMapper->getLegacyMappings($id); + foreach ($legacyMappings as $legacyMapping) { + try { + $this->legacySharingManager->deleteShare($legacyMapping->getLegacyShare()); + } catch (ShareNotFound) { + } + } + + $this->legacyMapper->deleteLegacyMappings($id); + }); + } + + return; + } + + /** @psalm-suppress RedundantConditionGivenDocblockType */ + if ($event instanceof SharesUpdatedEvent) { + foreach ($event->getShares() as $share) { + $this->wrapSync($share->id, function () use ($share): void { + // Only active shares are synced, everything else is dropped + if ($share->state === ShareState::Active) { + $this->syncShareToLegacyShares($share); + } else { + $legacyMappings = $this->legacyMapper->getLegacyMappings($share->id); + foreach ($legacyMappings as $legacyMapping) { + try { + $this->legacySharingManager->deleteShare($legacyMapping->getLegacyShare()); + } catch (ShareNotFound) { + } + } + + $this->legacyMapper->deleteLegacyMappings($share->id); + } + }); + + // This has no validation mode, because we don't have existing integration tests for it. + } + + return; + } + } + + /** + * @param (Closure(): void) $callback + */ + private function wrapSync(string $id, Closure $callback): void { + if (isset($this->sharesInSync[$id])) { + return; + } + + try { + $this->sharesInSync[$id] = true; + $this->dbConnection->beginTransaction(); + + $callback(); + + $this->dbConnection->commit(); + unset($this->sharesInSync[$id]); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + unset($this->sharesInSync[$id]); + throw $exception; + } + } + + /** + * @param non-empty-string $id + * @param non-empty-list $legacyMappings + */ + private function syncLegacySharesToShare(string $id, array $legacyMappings): void { + // TODO: Instead of using the first legacy share for common attributes, use the mostly recently updated one. Then also update the common stuff for the other legacy shares? + + /** @var array */ + $sources = []; + /** @var array, array> */ + $recipients = []; + $legacyLastUpdated = null; + + /** @var list $legacyShares */ + $legacyShares = []; + foreach ($legacyMappings as $legacyMapping) { + if ($legacyLastUpdated === null || $legacyLastUpdated->diff($legacyMapping->lastUpdated)->invert === 0) { + $legacyLastUpdated = $legacyMapping->lastUpdated; + } + + $legacyShare = $legacyMapping->getLegacyShare(); + $legacyShares[] = $legacyShare; + } + + /** @var array, SharePermission> */ + $permissions = []; + foreach ($legacyShares as $legacyShare) { + // Only allow if both agree, to avoid mixups. + $allowDownload = !$legacyShare->getHideDownload() && $legacyShare->getAttributes()?->getAttribute('permissions', 'download') !== false; + // We want the sum of all enabled permissions. + foreach ($this->legacyPermissionsToPermissions($legacyShare->getPermissions(), $allowDownload) as $permission) { + if (!isset($permissions[$permission->class]) || !$permissions[$permission->class]->enabled) { + $permissions[$permission->class] = $permission; + } + } + } + + foreach ($legacyMappings as $legacyMapping) { + // TODO: Don't refetch share + $legacyShare = $legacyMapping->getLegacyShare(); + + $nodeId = $legacyShare->getNodeId(); + $sources[$nodeId] ??= new ShareSource(NodeShareSourceType::class, (string)$nodeId); + + $shareInitiator = $this->splitLegacySharedByOrOwner($legacyShare->getSharedBy()); + if ($shareInitiator['value'] === '') { + throw new RuntimeException('sharedBy value must not be an empty string.'); + } + + if ($shareInitiator['instance'] !== null) { + // This should never happen, because incoming legacy remote shares are handled separately. + throw new RuntimeException('Incoming remote shares are not supported.'); + } + + $initiatorUser = new ShareUser( + $shareInitiator['value'], + $shareInitiator['instance'], + ); + + // Only allow if both agree, to avoid mixups. + $recipientAllowDownload = !$legacyShare->getHideDownload() && $legacyShare->getAttributes()?->getAttribute('permissions', 'download') !== false; + // We only want to set those permissions that are masked and not already denied by the share itself. + $recipientPermissions = array_filter($this->legacyPermissionsToPermissions($legacyShare->getPermissions(), $recipientAllowDownload), fn (SharePermission $permission): bool => !$permission->enabled && (!isset($permissions[$permission->class]) || $permissions[$permission->class]->enabled)); + + if ($legacyShare->getAttributes()?->getAttribute('fileRequest', 'enabled') === true) { + $emails = $legacyShare->getAttributes()?->getAttribute('shareWith', 'emails') ?? []; + if (!is_array($emails) || !array_is_list($emails) || array_any($emails, static fn (mixed $value): bool => !is_string($value) || $value === '')) { + throw new RuntimeException('The emails must be a list of non-empty strings.'); + } + + if (count($legacyMapping->recipientSecrets) !== count($emails) + 1) { + throw new RuntimeException('The secrets count does not match.'); + } + + if ($legacyMapping->recipientValue === '') { + throw new RuntimeException('Recipient value must not be an empty string.'); + } + + $recipients[TokenShareRecipientType::class] ??= []; + $recipients[TokenShareRecipientType::class][$this->getLegacySharedWithUnique($legacyShare)] ??= new ShareRecipient( + TokenShareRecipientType::class, + $legacyMapping->recipientValue, + null, + // TODO: Make sure mapping secret is updated if token changes (for all mappings of the same recipient). + $legacyMapping->recipientSecrets[0], + $initiatorUser, + $recipientPermissions, + ); + + /** @var list $emails */ + foreach ($emails as $index => $email) { + $recipients[EmailShareRecipientType::class] ??= []; + $recipients[EmailShareRecipientType::class][$email] ??= new ShareRecipient( + EmailShareRecipientType::class, + $email, + null, + // TODO: Make sure mapping secret is updated if token changes (for all mappings of the same recipient). + $legacyMapping->recipientSecrets[$index + 1], + $initiatorUser, + $recipientPermissions, + ); + } + } else { + $recipientTypeClass = $this->legacyShareTypeToRecipientTypeClass($legacyShare->getShareType(), $legacyShare->getAttributes()); + $isTokenRecipient = $recipientTypeClass === TokenShareRecipientType::class; + $recipient = $isTokenRecipient ? ['value' => $legacyMapping->recipientValue, 'instance' => null] : $this->splitLegacySharedWith($legacyShare); + $recipientValue = $recipient['value']; + if ($recipientValue === '') { + throw new RuntimeException('Recipient value must not be an empty string.'); + } + + $recipients[$recipientTypeClass] ??= []; + $recipients[$recipientTypeClass][$this->getLegacySharedWithUnique($legacyShare)] ??= new ShareRecipient( + $recipientTypeClass, + $recipientValue, + $recipient['instance'], + // TODO: Make sure mapping secret is updated if token changes (for all mappings of the same recipient). + $legacyMapping->recipientSecrets[0], + $initiatorUser, + $recipientPermissions, + ); + } + } + + $shareOwner = $this->splitLegacySharedByOrOwner($legacyShares[0]->getShareOwner()); + if ($shareOwner['value'] === '') { + throw new RuntimeException('shareOwner value must not be an empty string.'); + } + + if ($shareOwner['instance'] !== null) { + // This should never happen, because incoming legacy remote shares are handled separately. + throw new RuntimeException('Incoming remote shares are not supported.'); + } + + $owner = new ShareUser( + $shareOwner['value'], + $shareOwner['instance'], + ); + + $password = $legacyShares[0]->getPassword(); + if ($password !== null && !$legacyShare->isPasswordHashed()) { + throw new RuntimeException('The password must be hashed already.'); + } + + $properties = [ + new ShareProperty(ExpirationDateSharePropertyType::class, $legacyShares[0]->getExpirationDate()?->format(DateTimeInterface::ATOM)), + new ShareProperty(PasswordSharePropertyType::class, $password), + new ShareProperty(LabelSharePropertyType::class, ($label = $legacyShares[0]->getLabel()) !== '' ? $label : null), + new ShareProperty(NoteSharePropertyType::class, ($note = $legacyShares[0]->getNote()) !== '' ? $note : null), + new ShareProperty(NodeGridViewSharePropertyType::class, $legacyShares[0]->getAttributes()?->getAttribute('config', 'grid_view') === true ? 'true' : 'false'), + new ShareProperty(NodeNicknameSharePropertyType::class, $legacyShares[0]->getAttributes()?->getAttribute('fileRequest', 'enabled') === true ? 'true' : 'false'), + ]; + + // To avoid diffing the shares, we just delete and create it. + try { + $this->backend->deleteShare($id); + } catch (ShareNotFoundException) { + } + + $this->createShare($legacyShares, new Share( + $id, + $owner, + $legacyLastUpdated, + // Legacy shares are always active + ShareState::Active, + null, + array_values($sources), + array_values(array_merge(...array_values($recipients))), + array_combine(array_map(static fn (ShareProperty $property): string => $property->class, $properties), $properties), + array_combine(array_map(static fn (SharePermission $permission): string => $permission->class, $permissions), $permissions), + )); + } + + /** + * @param list $legacyShares + */ + private function createShare(array $legacyShares, Share $share): Share { + $this->backend->createShare($share->id, $share->owner, $share->lastUpdated); + $this->backend->updateShareState($share->id, $share->state); + + foreach ($share->sources as $source) { + $this->backend->addShareSource($share->id, $source); + } + + foreach ($share->recipients as $recipient) { + $this->backend->addShareRecipient($share->id, $recipient); + + foreach ($recipient->permissions as $permission) { + $this->backend->updateShareRecipientPermission($share->id, $recipient, $permission); + } + } + + foreach ($share->properties as $property) { + $this->backend->updateShareProperty($share->id, $property); + } + + foreach ($share->permissions as $permission) { + $this->backend->updateSharePermission($share->id, $permission); + } + + foreach ($legacyShares as $legacyShare) { + /** {@see IShare::TYPE_USERGROUP} shares are never handled directly here */ + if ($legacyShare->getShareType() === IShare::TYPE_USER) { + $legacySharedWith = $legacyShare->getSharedWith(); + if ($legacySharedWith === '') { + throw new RuntimeException('Shared with must be a non-empty string.'); + } + + $this->backend->updateShareUserStatus($share->id, $legacySharedWith, $this->legacyUserStatusToUserStatus($legacyShare->getStatus() ?? IShare::STATUS_PENDING)); + $this->getSourceNodeTargetManager()->setTarget($legacySharedWith, $share->owner, $legacyShare->getNode(), $legacyShare->getTarget()); + } elseif ($legacyShare->getShareType() === IShare::TYPE_GROUP) { + $qb = $this->dbConnection->getQueryBuilder(); + $result = $qb + ->select('share_with', 'accepted', 'file_target', 'permissions') + ->from('share') + ->where($qb->expr()->eq('share_type', $qb->createNamedParameter(IShare::TYPE_USERGROUP))) + ->andWhere($qb->expr()->eq('parent', $qb->createNamedParameter($legacyShare->getId(), IQueryBuilder::PARAM_INT))) + ->executeQuery(); + + /** @var list $rows */ + $rows = $result->fetchAllAssociative(); + foreach ($rows as $row) { + if ($row['permissions'] === 0) { + $this->backend->updateShareUserStatus($share->id, $row['share_with'], ShareUserStatus::Rejected); + } else { + /** @psalm-suppress ArgumentTypeCoercion */ + $this->backend->updateShareUserStatus($share->id, $row['share_with'], $this->legacyUserStatusToUserStatus((int)$row['accepted'])); + } + + $this->getSourceNodeTargetManager()->setTarget($row['share_with'], $share->owner, $legacyShare->getNode(), $row['file_target']); + } + } + } + + [$share] = $this->backend->ensureDefaults([$share]); + + return $share; + } + + private function syncShareToLegacyShares(Share $share): void { + if ($share->owner->instance !== null) { + // We can't save them, because they are handled separately in the legacy sharing system. + return; + } + + /** @var array>> $legacyMappings */ + $legacyMappings = []; + /** @var array */ + $oldLegacyShares = []; + foreach ($this->legacyMapper->getLegacyMappings($share->id) as $legacyMapping) { + $legacyShare = $legacyMapping->getLegacyShare(); + + $legacyShareType = $legacyShare->getShareType(); + $legacySharedWithUnique = $this->getLegacySharedWithUnique($legacyShare); + + $legacyMappings[$legacyShareType] ??= []; + $legacyMappings[$legacyShareType][$legacySharedWithUnique] ??= []; + $legacyMappings[$legacyShareType][$legacySharedWithUnique][$legacyShare->getNodeId()] = $legacyMapping; + + $oldLegacyShares[$legacyShare->getFullId()] = $legacyShare; + } + + if ($share->recipients === []) { + return; + } + + /** @var array */ + $newLegacyShares = []; + foreach ($share->sources as $source) { + if ($source->class !== NodeShareSourceType::class) { + continue; + } + + if (($share->properties[NodeNicknameSharePropertyType::class] ?? null)?->value === 'true') { + $tokenRecipient = null; + $emailRecipients = []; + foreach ($share->recipients as $recipient) { + if ($recipient->class === TokenShareRecipientType::class) { + if ($tokenRecipient !== null) { + throw new RuntimeException('More than one token recipient not allowed.'); + } + + $tokenRecipient = $recipient; + continue; + } + + if ($recipient->class === EmailShareRecipientType::class) { + $emailRecipients[] = $recipient; + continue; + } + + throw new RuntimeException('Recipient type that is not a token or email: ' . $recipient->class); + } + + if ($tokenRecipient === null) { + throw new RuntimeException('No token recipient.'); + } + + if ($tokenRecipient->initiator === null) { + throw new RuntimeException('Token recipient initiator cannot be null at this point.'); + } + + if ($tokenRecipient->secret === null) { + throw new RuntimeException('Token recipient secret cannot be null at this point.'); + } + + $attributes = new ShareAttributes(); + + // Create a new object to avoid copying existing attributes. + $legacyShare = $this->legacySharingManager->newShare(); + $legacyShare->setShareType(IShare::TYPE_EMAIL); + $legacyShare->setSharedWith(''); + + $attributes->setAttribute('shareWith', 'emails', array_map(static fn (ShareRecipient $recipient): string => $recipient->value, $emailRecipients)); + + $legacyShare->setSharedBy($this->shareUserToLegacyIdentifier($tokenRecipient->initiator)); + + // Make sure the token recipient is always the first one, so we can correctly assign back the secrets later. + $orderedRecipients = array_merge([$tokenRecipient], $emailRecipients); + $orderedRecipientSecrets = array_map(function (ShareRecipient $recipient): string { + if ($recipient->secret === null) { + throw new RuntimeException('The secret must not be null.'); + } + + return $recipient->secret; + }, $orderedRecipients); + + $legacyShare->setToken($this->isPublicLegacyShare($legacyShare) ? $tokenRecipient->secret : ''); + + $legacyShare->setStatus(IShare::STATUS_PENDING); + + $legacyShare->setAttributes($attributes); + + $this->setCommonLegacyShareFields($share, $source, $tokenRecipient, $legacyShare); + + $legacyShare = $this->createOrUpdateLegacyShare($share, $legacyMappings, $legacyShare, $tokenRecipient->value, $orderedRecipientSecrets); + + $newLegacyShares[$legacyShare->getFullId()] = $legacyShare; + } else { + $ownerUserFolder = null; + $sourceNode = null; + + foreach ($share->recipients as $recipient) { + /** {@see IShare::TYPE_USERGROUP} shares are handled automatically by the DefaultShareProvider. */ + /** @psalm-suppress ArgumentTypeCoercion Psalm gets confused with the properties keys */ + $legacyShareType = $this->recipientTypeClassToLegacyShareType($recipient->class, $recipient->instance, $share->properties); + if ($legacyShareType === null) { + continue; + } + + // Create a new object to avoid copying existing attributes. + $legacyShare = $this->legacySharingManager->newShare(); + $legacyShare->setShareType($legacyShareType); + + /** @psalm-suppress ArgumentTypeCoercion Psalm gets confused with the properties keys */ + if (($legacySharedBy = $this->shareRecipientToLegacyIdentifier($recipient, $share->properties)) !== null) { + $legacyShare->setSharedWith($legacySharedBy); + } + + if ($recipient->initiator === null) { + throw new RuntimeException('Share recipient initiator cannot be null at this point.'); + } + + $legacyShare->setSharedBy($this->shareUserToLegacyIdentifier($recipient->initiator)); + + $recipientSecret = $recipient->secret; + if ($recipientSecret === null) { + throw new RuntimeException('secret must be set.'); + } + + $legacyShare->setToken($this->isPublicLegacyShare($legacyShare) || $this->isRemoteLegacyShare($legacyShare) ? $recipientSecret : ''); + + $legacyShare->setStatus( + $this->userStatusToLegacyUserStatus( + ( + $recipient->class === UserShareRecipientType::class && $recipient->instance === null + ? ($this->backend->getUserStatuses($share->id, [$recipient->value])[$recipient->value] ?? null) + : null + ) ?? ShareUserStatus::Pending + ) + ); + + if ($recipient->class === UserShareRecipientType::class && $recipient->instance === null) { + $ownerUserFolder ??= $this->rootFolder->getUserFolder($share->owner->userId); + $sourceNode ??= $ownerUserFolder->getFirstNodeById((int)$source->value); + if (!$sourceNode instanceof Node) { + throw new NotFoundException(); + } + + $target = $this->getSourceNodeTargetManager()->getTarget($recipient->value, $share->owner, $sourceNode) ?? $this->getSourceNodeTargetManager()->createDefaultTarget($recipient->value, $share->owner, $sourceNode); + $legacyShare->setTarget($target); + } + + $this->setCommonLegacyShareFields($share, $source, $recipient, $legacyShare); + + $legacyShare = $this->createOrUpdateLegacyShare($share, $legacyMappings, $legacyShare, $recipient->value, [$recipientSecret]); + + // Child shares might automatically get accepted, so we need to update their user status. + // We don't need to update any legacy share objects, because the group child shares are handled completely outside of this sync (which is the reason why we're doing the following directly in the DB). + if ($recipient->class === GroupShareRecipientType::class) { + if (($recipientType = $this->sharingRegistry->getRecipientTypes()[$recipient->class] ?? null) === null) { + throw new RuntimeException('The recipient type is not registered: ' . $recipient->class); + } + + $ownerUserFolder ??= $this->rootFolder->getUserFolder($share->owner->userId); + $sourceNode ??= $ownerUserFolder->getFirstNodeById((int)$source->value); + if (!$sourceNode instanceof Node) { + throw new NotFoundException(); + } + + $userIds = array_map(static fn (IUser $user): string => $user->getUID(), $recipientType->getUsers($recipient->value)); + + $userIdsByUserStatus = []; + foreach ($this->backend->getUserStatuses($share->id, $userIds) as $userId => $userStatus) { + $userIdsByUserStatus[$userStatus->value] ??= []; + $userIdsByUserStatus[$userStatus->value][] = $userId; + } + + foreach ($userIdsByUserStatus as $userStatus => $ids) { + foreach (array_chunk($ids, 1000) as $chunk) { + $qb = $this->dbConnection->getQueryBuilder(); + $qb + ->update('share') + ->where($qb->expr()->eq('share_type', $qb->createNamedParameter(IShare::TYPE_USERGROUP, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('parent', $qb->createNamedParameter((int)$legacyShare->getId(), IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->in('share_with', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_STR_ARRAY))); + if ($userStatus === ShareUserStatus::Rejected->value) { + $qb + ->set('permissions', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)); + } else { + $qb + ->set('accepted', $qb->createNamedParameter($this->userStatusToLegacyUserStatus(ShareUserStatus::from($userStatus)), IQueryBuilder::PARAM_INT)); + } + + $qb->executeStatement(); + } + } + + $userIdsByTarget = []; + foreach ($this->getSourceNodeTargetManager()->getTargets($userIds, $share->owner, $sourceNode) as $userId => $target) { + $userIdsByTarget[$target] ??= []; + $userIdsByTarget[$target][] = $userId; + } + + foreach ($userIdsByTarget as $target => $ids) { + foreach (array_chunk($ids, 1000) as $chunk) { + $qb = $this->dbConnection->getQueryBuilder(); + $qb + ->update('share') + ->set('file_target', $qb->createNamedParameter($target)) + ->where($qb->expr()->eq('share_type', $qb->createNamedParameter(IShare::TYPE_USERGROUP, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('parent', $qb->createNamedParameter((int)$legacyShare->getId(), IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->in('share_with', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_STR_ARRAY))) + ->executeStatement(); + } + } + } + + $newLegacyShares[$legacyShare->getFullId()] = $legacyShare; + } + } + } + + foreach (array_diff(array_keys($oldLegacyShares), array_keys($newLegacyShares)) as $invalidLegacyShareId) { + $this->legacySharingManager->deleteShare($oldLegacyShares[$invalidLegacyShareId]); + } + + foreach ($newLegacyShares as $legacyShare) { + $this->legacySharingManager->updateShare($legacyShare); + } + } + + private function setCommonLegacyShareFields(Share $share, ShareSource $source, ShareRecipient $recipient, IShare $legacyShare): void { + $attributes = $legacyShare->getAttributes() ?? new ShareAttributes(); + + $legacyShare->setNodeId((int)$source->value); + + $legacyShare->setShareTime(DateTime::createFromImmutable($share->getCreatedAt())); + + [$permissions, $allowDownload] = $this->permissionsToLegacyPermissions($source, $share->getEffectiveEnabledPermissionsForRecipients([$recipient])); + $legacyShare->setPermissions($permissions); + + // Always set both, to avoid mixups. + $attributes->setAttribute('permissions', 'download', $allowDownload); + $legacyShare->setHideDownload(!$allowDownload); + + if (($share->properties[NodeNicknameSharePropertyType::class] ?? null)?->value === 'true') { + $attributes->setAttribute('fileRequest', 'enabled', true); + } + + $legacyShare->setShareOwner($this->shareUserToLegacyIdentifier($share->owner)); + + $legacyShare->setNote(($share->properties[NoteSharePropertyType::class] ?? null)?->value ?? ''); + + $legacyShare->setLabel($this->isPublicLegacyShare($legacyShare) ? ($share->properties[LabelSharePropertyType::class] ?? null)?->value ?? '' : ''); + + if (($share->properties[NodeGridViewSharePropertyType::class] ?? null)?->value === 'true') { + $attributes->setAttribute('config', 'grid_view', true); + } + + $expirationDate = ($share->properties[ExpirationDateSharePropertyType::class] ?? null)?->value; + $expirationDate = $expirationDate !== null ? DateTime::createFromFormat(DateTimeInterface::ATOM, $expirationDate) : null; + if ($expirationDate === false) { + throw new RuntimeException('Failed to parse expiration date.'); + } + + $legacyShare->setExpirationDate($expirationDate); + // We don't call setNoExpirationDate, because the value isn't actually saved + + if (($passwordHash = ($share->properties[PasswordSharePropertyType::class] ?? null)?->value) !== null) { + $legacyShare->setPasswordHash($passwordHash); + } + + $legacyShare->setAttributes($attributes); + } + + /** + * @param array>> $legacyMappings + * @param non-empty-string $recipientValue + * @param non-empty-list $recipientSecrets + */ + private function createOrUpdateLegacyShare(Share $share, array $legacyMappings, IShare $legacyShare, string $recipientValue, array $recipientSecrets): IShare { + if (($legacyMapping = $legacyMappings[$legacyShare->getShareType()][$this->getLegacySharedWithUnique($legacyShare)][$legacyShare->getNodeId()] ?? null) !== null) { + $oldLegacyShare = $legacyMapping->getLegacyShare(); + + $legacyShare->setId($oldLegacyShare->getId()); + $legacyShare->setProviderId($oldLegacyShare->getProviderId()); + + $this->legacyMapper->updateLegacyMapping(new LegacyMapping( + $legacyMapping->id, + $legacyMapping->legacyProviderClass, + $legacyMapping->legacyId, + $share->lastUpdated, + $legacyMapping->recipientSecrets, + $legacyMapping->recipientValue, + )); + } else { + try { + // We need to create the legacy mapping ourselves to control the share id, so we disable the automapping of new legacy shares. + $this->ignoreNewLegacyShare = true; + // This is the only way we can get a provider and id assigned. + $legacyShare = $this->legacySharingManager->createShare($legacyShare); + } catch (Exception $exception) { + throw $exception; + } finally { + $this->ignoreNewLegacyShare = false; + } + + $this->legacyMapper->createLegacyMapping( + $share->id, + $this->legacyShareProviderFactory->getProvider($legacyShare->getProviderId())::class, + (int)$legacyShare->getId(), + $share->lastUpdated, + $recipientSecrets, + $recipientValue, + ); + } + + return $legacyShare; + } + + /** + * @return IShare::STATUS_* + */ + private function userStatusToLegacyUserStatus(ShareUserStatus $userStatus): int { + return match ($userStatus) { + ShareUserStatus::Accepted => IShare::STATUS_ACCEPTED, + ShareUserStatus::Pending => IShare::STATUS_PENDING, + ShareUserStatus::Rejected => IShare::STATUS_REJECTED, + }; + } + + /** + * @param IShare::STATUS_* $legacyUserStatus + */ + private function legacyUserStatusToUserStatus(int $legacyUserStatus): ShareUserStatus { + return match ($legacyUserStatus) { + IShare::STATUS_ACCEPTED => ShareUserStatus::Accepted, + IShare::STATUS_PENDING => ShareUserStatus::Pending, + IShare::STATUS_REJECTED => ShareUserStatus::Rejected, + }; + } + + /** + * @param list> $permissions + * @return array{int-mask-of, bool} + */ + private function permissionsToLegacyPermissions(ShareSource $source, array $permissions): array { + $qb = $this->dbConnection->getQueryBuilder(); + $result = $qb + ->select('mimetype') + ->from('filecache') + ->where($qb->expr()->eq('fileid', $qb->createNamedParameter((int)$source->value, IQueryBuilder::PARAM_INT))) + ->executeQuery(); + + /** @var int|string|false */ + $mimeTypeId = $result->fetchOne(); + if ($mimeTypeId === false) { + throw new RuntimeException('Share source does not exist: ' . $source->value); + } + + $nodeIsFile = $this->mimeTypeLoader->getMimetypeById((int)$mimeTypeId) !== 'httpd/unix-directory'; + + /** @var int-mask-of $legacyPermissions */ + $legacyPermissions = 0; + $allowDownload = false; + + if (in_array(NodeReadSharePermissionType::class, $permissions, true)) { + $legacyPermissions |= Constants::PERMISSION_READ; + } + + if (in_array(NodeUpdateSharePermissionType::class, $permissions, true)) { + $legacyPermissions |= Constants::PERMISSION_UPDATE; + } + + if (!$nodeIsFile && in_array(NodeCreateSharePermissionType::class, $permissions, true)) { + $legacyPermissions |= Constants::PERMISSION_CREATE; + } + + if (!$nodeIsFile && in_array(NodeDeleteSharePermissionType::class, $permissions, true)) { + $legacyPermissions |= Constants::PERMISSION_DELETE; + } + + if (in_array(ReshareSharePermissionType::class, $permissions, true)) { + $legacyPermissions |= Constants::PERMISSION_SHARE; + } + + if (in_array(NodeDownloadSharePermissionType::class, $permissions, true)) { + $allowDownload = true; + } + + return [$legacyPermissions, $allowDownload]; + } + + /** + * @param int-mask-of $legacyPermissions + * @return array, SharePermission> + */ + private function legacyPermissionsToPermissions(int $legacyPermissions, bool $allowDownload): array { + /** @var array, SharePermission> $permissions */ + $permissions = []; + + foreach ([ + NodeReadSharePermissionType::class => Constants::PERMISSION_READ, + NodeUpdateSharePermissionType::class => Constants::PERMISSION_UPDATE, + NodeCreateSharePermissionType::class => Constants::PERMISSION_CREATE, + NodeDeleteSharePermissionType::class => Constants::PERMISSION_DELETE, + ReshareSharePermissionType::class => Constants::PERMISSION_SHARE, + ] as $permissionTypeClass => $mask) { + $permissions[$permissionTypeClass] = new SharePermission($permissionTypeClass, ($legacyPermissions & $mask) === $mask); + } + + $permissions[NodeDownloadSharePermissionType::class] = new SharePermission(NodeDownloadSharePermissionType::class, $allowDownload); + + return $permissions; + } + + /** + * @param array, ShareProperty> $shareProperties + */ + private function shareRecipientToLegacyIdentifier(ShareRecipient $recipient, array $shareProperties): ?string { + if (($shareProperties[NodeNicknameSharePropertyType::class] ?? null)?->value === 'true') { + return ''; + } + + if ($recipient->instance !== null) { + $cloudId = $this->cloudIdManager->getCloudId($recipient->value, $recipient->instance); + return $cloudId->getUser() . '@' . $cloudId->getRemote(); + } + + if ($recipient->class === TokenShareRecipientType::class) { + return null; + } + + return $recipient->value; + } + + private function shareUserToLegacyIdentifier(ShareUser $user): string { + if ($user->instance !== null) { + $cloudId = $this->cloudIdManager->getCloudId($user->userId, $user->instance); + return $cloudId->getUser() . '@' . $cloudId->getRemote(); + } + + return $user->userId; + } + + /** + * @param class-string $recipientTypeClass + * @param array, ShareProperty> $shareProperties + * @return ?IShare::TYPE_* + */ + private function recipientTypeClassToLegacyShareType(string $recipientTypeClass, ?string $recipientInstance, array $shareProperties): ?int { + if (($shareProperties[NodeNicknameSharePropertyType::class] ?? null)?->value === 'true') { + return IShare::TYPE_EMAIL; + } + + return match ($recipientTypeClass) { + UserShareRecipientType::class => $recipientInstance === null ? IShare::TYPE_USER : IShare::TYPE_REMOTE, + GroupShareRecipientType::class => $recipientInstance === null ? IShare::TYPE_GROUP : IShare::TYPE_REMOTE_GROUP, + TokenShareRecipientType::class => IShare::TYPE_LINK, + EmailShareRecipientType::class => IShare::TYPE_EMAIL, + TeamShareRecipientType::class => IShare::TYPE_CIRCLE, + // TODO talk, deck + default => null, + }; + } + + /** + * @param IShare::TYPE_* $legacyShareType + * @return class-string + */ + private function legacyShareTypeToRecipientTypeClass(int $legacyShareType, ?IAttributes $attributes): string { + if ($attributes?->getAttribute('fileRequest', 'enabled') === true) { + return TokenShareRecipientType::class; + } + + return match ($legacyShareType) { + IShare::TYPE_USER, IShare::TYPE_REMOTE => UserShareRecipientType::class, + IShare::TYPE_GROUP, IShare::TYPE_REMOTE_GROUP => GroupShareRecipientType::class, + IShare::TYPE_LINK => TokenShareRecipientType::class, + IShare::TYPE_EMAIL => EmailShareRecipientType::class, + IShare::TYPE_CIRCLE => TeamShareRecipientType::class, + // TODO talk, deck + default => throw new RuntimeException('Unsupported legacy share type: ' . $legacyShareType), + }; + } + + /** + * @return array{value: string, instance: non-empty-string|null} + */ + private function splitLegacySharedWith(IShare $legacyShare): array { + if ($legacyShare->getShareType() === IShare::TYPE_REMOTE || $legacyShare->getShareType() === IShare::TYPE_REMOTE_GROUP) { + $cloudId = $this->cloudIdManager->resolveCloudId($legacyShare->getSharedWith()); + + $user = $cloudId->getUser(); + if ($user === '') { + throw new RuntimeException('User must not be an empty string.'); + } + + $remote = $cloudId->getRemote(); + if ($remote === '') { + throw new RuntimeException('Remote must not be an empty string.'); + } + + return [ + 'value' => $user, + 'instance' => $remote, + ]; + } + + return [ + // This can be an empty string, in case it is a file request + 'value' => $legacyShare->getSharedWith(), + 'instance' => null, + ]; + } + + /** + * @return array{value: string, instance: non-empty-string|null} + */ + private function splitLegacySharedByOrOwner(string $sharedByOrOwner): array { + // The type of the legacy share is only for the sharedWith, but we also can't have an email here, so we just have to check if it is a Cloud ID. + if ($this->cloudIdManager->isValidCloudId($sharedByOrOwner)) { + $cloudId = $this->cloudIdManager->resolveCloudId($sharedByOrOwner); + + $user = $cloudId->getUser(); + if ($user === '') { + throw new RuntimeException('User must not be an empty string.'); + } + + $remote = $cloudId->getRemote(); + if ($remote === '') { + throw new RuntimeException('Remote must not be an empty string.'); + } + + return [ + 'value' => $user, + 'instance' => $remote, + ]; + } + + return [ + 'value' => $sharedByOrOwner, + 'instance' => null, + ]; + } + + private function getLegacySharedWithUnique(IShare $legacyShare): string { + return $this->isPublicLegacyShare($legacyShare) ? $legacyShare->getToken() : $legacyShare->getSharedWith(); + } +} diff --git a/lib/private/Sharing/SharingManager.php b/lib/private/Sharing/SharingManager.php index e062f7f909103..17183308d88bf 100644 --- a/lib/private/Sharing/SharingManager.php +++ b/lib/private/Sharing/SharingManager.php @@ -877,8 +877,11 @@ public function handle(Event $event): void { $keys = array_keys($shares); $shares = $this->processShareUpdates(array_values($shares)); $event->setShares(array_combine($keys, $shares)); + + return; } + /** @psalm-suppress RedundantConditionGivenDocblockType */ if ($event instanceof BeforeUserDeletedEvent) { $shareUser = new ShareUser($event->getUser()->getUID(), null); @@ -891,6 +894,8 @@ public function handle(Event $event): void { $this->dbConnection->rollBack(); throw $exception; } + + return; } } diff --git a/psalm-strict.xml b/psalm-strict.xml index 3fd98bc95f952..19da06773e4f1 100644 --- a/psalm-strict.xml +++ b/psalm-strict.xml @@ -85,6 +85,7 @@ + @@ -93,6 +94,7 @@ + diff --git a/tests/lib/Sharing/SharingLegacySyncTest.php b/tests/lib/Sharing/SharingLegacySyncTest.php new file mode 100644 index 0000000000000..a96dc43c48cbd --- /dev/null +++ b/tests/lib/Sharing/SharingLegacySyncTest.php @@ -0,0 +1,2121 @@ +sharingRegistry = Server::get(ISharingRegistry::class); + $this->sharingManager = Server::get(ISharingManager::class); + $this->sharingBackend = Server::get(ISharingBackend::class); + $this->legacySharingManager = Server::get(IManager::class); + $this->legacySync = Server::get(SharingLegacySync::class); + $this->legacyMapper = Server::get(LegacyMapper::class); + $this->dbConnection = Server::get(IDBConnection::class); + $this->snowflakeGenerator = Server::get(ISnowflakeGenerator::class); + $this->snowflakeDecoder = Server::get(ISnowflakeDecoder::class); + $this->sourceNodeTargetManager = Server::get(SourceNodeTargetManager::class); + + $this->sharingRegistry->clear(); + $this->invokePrivate(Server::get(Application::class), 'registerSharing'); + $this->invokePrivate(Server::get(\OCA\Files\AppInfo\Application::class), 'registerSharing'); + + $this->owner = $this->createUser('owner', 'password'); + $this->user1 = $this->createUser('user1', 'password'); + $this->user2 = $this->createUser('user2', 'password'); + $this->group = $this->createGroup('group'); + $this->groupBackend->addToGroup($this->user1->getUID(), $this->group->getGID()); + + $ownerFolder = Server::get(IRootFolder::class)->getUserFolder($this->owner->getUID()); + $this->nodeFolder = $ownerFolder->newFolder('foo'); + $this->nodeFile = $ownerFolder->newFile('foo.txt'); + + /** @var Notifications $realNotifications */ + $realNotifications = $this->invokePrivate(Server::get(FederatedShareProvider::class), 'notifications'); + $this->realNotifications = $realNotifications; + $this->invokePrivate(Server::get(FederatedShareProvider::class), 'notifications', [$this->getAutoMock(Notifications::class)]); + + $this->dbConnection->beginTransaction(); + } + + #[\Override] + public function tearDown(): void { + $this->clearShares(); + + $this->invokePrivate(Server::get(FederatedShareProvider::class), 'notifications', [$this->realNotifications]); + + $this->sharingRegistry->clear(); + + Server::get(ISetupManager::class)->tearDown(); + + // Can't rollback, because it would rollback the classmap data, but the ClassMapper still has the values cached. + $this->dbConnection->commit(); + parent::tearDown(); + } + + private function clearShares(): void { + foreach ($this->legacySharingManager->getAllShares() as $legacyShare) { + $this->legacySharingManager->deleteShare($legacyShare); + } + + $accessContext = new ShareAccessContext(overrideChecks: true); + foreach ($this->sharingManager->getShares($accessContext, null, null, null, null, null, null) as $share) { + $this->sharingManager->deleteShare($accessContext, $share); + } + + $qb = $this->dbConnection->getQueryBuilder(); + $qb + ->delete('sharing_source_node_target') + ->executeStatement(); + } + + /** + * {@see \OCP\Share\IManager::getAllShares()} does not return shares of {@see IShare::TYPE_USERGROUP}, which we need to verify the behavior. + * @return list + */ + private function getAllLegacyShares(): array { + $providers = Server::get(IProviderFactory::class)->getAllProviders(); + + $legacyShares = []; + foreach ($providers as $provider) { + if ($provider->identifier() === 'ocinternal') { + /** @var DefaultShareProvider $provider */ + $legacyShares[] = array_values(iterator_to_array($provider->getAllShares(true))); + } else { + $legacyShares[] = array_values(iterator_to_array($provider->getAllShares())); + } + } + + return array_merge(...$legacyShares); + } + + /** + * @param array $shareUserStatuses + */ + private function createShare(Share $share, array $shareUserStatuses): void { + $this->sharingBackend->createShare($share->id, $share->owner, $share->getCreatedAt()); + + foreach ($share->sources as $source) { + $this->sharingBackend->addShareSource($share->id, $source); + } + + foreach ($share->recipients as $recipient) { + $this->sharingBackend->addShareRecipient($share->id, $recipient); + + foreach ($recipient->permissions as $permission) { + $this->sharingBackend->updateShareRecipientPermission($share->id, $recipient, $permission); + } + } + + foreach ($share->properties as $property) { + $this->sharingBackend->updateShareProperty($share->id, $property); + } + + foreach ($share->permissions as $permission) { + $this->sharingBackend->updateSharePermission($share->id, $permission); + } + + if ($share->userStatus instanceof \NCU\Sharing\ShareUserStatus) { + throw new RuntimeException('Do not pass a user status directly.'); + } + + foreach ($shareUserStatuses as $userId => $userStatus) { + $this->sharingBackend->updateShareUserStatus($share->id, $userId, $userStatus); + } + + // Only use the manager here, so the update event is dispatched. + $this->sharingManager->updateShareState(new ShareAccessContext(overrideChecks: true), $share, $share->state); + } + + /** + * @param list $legacyShares + * @return list + */ + private function createLegacyShares(array $legacyShares): array { + $legacyParentShares = array_values(array_filter($legacyShares, static fn (IShare $legacyShare): bool => $legacyShare->getShareType() !== IShare::TYPE_USERGROUP)); + /** @var non-empty-list $legacyParentShares */ + $this->assertNotEmpty($legacyParentShares); + foreach ($legacyParentShares as &$legacyShare) { + $legacyShareWithoutProvider = clone $legacyShare; + $this->invokePrivate($legacyShareWithoutProvider, 'providerId', [null]); + $legacyShareWithoutProvider = $this->legacySharingManager->createShare($legacyShareWithoutProvider); + // The share might get auto accepted, so we have to manually set the desired status. + $legacyShareWithoutProvider->setStatus($legacyShare->getStatus() ?? IShare::STATUS_PENDING); + $legacyShare = $this->legacySharingManager->updateShare($legacyShareWithoutProvider); + } + + // Ensure child shares have been created + $this->assertCount(count($legacyShares), $this->getAllLegacyShares()); + + $legacyChildShares = array_filter($legacyShares, static fn (IShare $legacyShare): bool => $legacyShare->getShareType() === IShare::TYPE_USERGROUP); + + $legacyChildShareStatuses = array_values(array_unique(array_map(static fn (IShare $legacyShare): ?int => $legacyShare->getStatus(), $legacyChildShares))); + if ($legacyChildShareStatuses !== []) { + if (count($legacyChildShareStatuses) > 1) { + throw new RuntimeException('More than one status value, not working yet.'); + } + + $this->assertNotNull($legacyChildShareStatuses[0]); + + // The child shares get auto accepted, so we have to manually set the desired status. + $qb = $this->dbConnection->getQueryBuilder(); + $rowCount = $qb + ->update('share') + ->set('accepted', $qb->createNamedParameter($legacyChildShareStatuses[0], IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('share_type', $qb->createNamedParameter(IShare::TYPE_USERGROUP))) + ->andWhere($qb->expr()->in('parent', $qb->createNamedParameter(array_map(static fn (IShare $legacyShare): int => (int)$legacyShare->getId(), $legacyParentShares), IQueryBuilder::PARAM_INT_ARRAY))) + ->executeStatement(); + $this->assertEquals(count(array_filter($legacyShares, static fn (IShare $legacyShare): bool => $legacyShare->getShareType() === IShare::TYPE_USERGROUP)), $rowCount); + } + + $legacyChildShareTargets = array_values(array_unique(array_map(static fn (IShare $legacyShare): string => $legacyShare->getTarget(), $legacyChildShares))); + if ($legacyChildShareTargets !== []) { + if (count($legacyChildShareTargets) > 1) { + throw new RuntimeException('More than one target value, not working yet.'); + } + + // The child shares get auto accepted, so we have to manually set the desired target. + $qb = $this->dbConnection->getQueryBuilder(); + $rowCount = $qb + ->update('share') + ->set('file_target', $qb->createNamedParameter($legacyChildShareTargets[0])) + ->where($qb->expr()->eq('share_type', $qb->createNamedParameter(IShare::TYPE_USERGROUP))) + ->andWhere($qb->expr()->in('parent', $qb->createNamedParameter(array_map(static fn (IShare $legacyShare): int => (int)$legacyShare->getId(), $legacyParentShares), IQueryBuilder::PARAM_INT_ARRAY))) + ->executeStatement(); + $this->assertEquals(count(array_filter($legacyShares, static fn (IShare $legacyShare): bool => $legacyShare->getShareType() === IShare::TYPE_USERGROUP)), $rowCount); + } + + $legacyChildSharePermissions = array_values(array_unique(array_map(static fn (IShare $legacyShare): int => $legacyShare->getPermissions(), $legacyChildShares))); + if ($legacyChildSharePermissions !== []) { + if (count($legacyChildSharePermissions) > 1) { + throw new RuntimeException('More than one permissions value, not working yet.'); + } + + // The child shares get auto accepted, so we have to manually set the desired permissions. + $qb = $this->dbConnection->getQueryBuilder(); + $rowCount = $qb + ->update('share') + ->set('permissions', $qb->createNamedParameter($legacyChildSharePermissions[0], IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('share_type', $qb->createNamedParameter(IShare::TYPE_USERGROUP))) + ->andWhere($qb->expr()->in('parent', $qb->createNamedParameter(array_map(static fn (IShare $legacyShare): int => (int)$legacyShare->getId(), $legacyParentShares), IQueryBuilder::PARAM_INT_ARRAY))) + ->executeStatement(); + $this->assertEquals(count(array_filter($legacyShares, static fn (IShare $legacyShare): bool => $legacyShare->getShareType() === IShare::TYPE_USERGROUP)), $rowCount); + } + + if ($legacyChildShareStatuses !== [] || $legacyChildShareTargets !== [] || $legacyChildSharePermissions !== []) { + // Just update anything to trigger the syncing + $this->legacySharingManager->updateShare($legacyParentShares[0]); + } + + return $legacyParentShares; + } + + private function fixLegacyShare(IShare $legacyShare): IShare { + // Having the node set seems to trigger and infinite loop in PHPUnit when comparing the objects. + $node = $legacyShare->getNode(); + $legacyShare->setNodeId($node->getId()); + $legacyShare->setNodeType($node instanceof File ? 'file': 'folder'); + + // There might be a closure that we can't reproduce and we want the value the closure returns to be set. + $legacyShare->getSharedWithDisplayName(); + + // The original target is only an internal property and not stored in the DB. + $this->invokePrivate($legacyShare, 'originalTarget', [null]); + + return $legacyShare; + } + + private function fixShare(Share $share): Share { + foreach ($share->sources as $source) { + $this->invokePrivate($source, 'getMetadata', [$this->sharingRegistry]); + } + + return $share; + } + + /** + * @param list $expected + * @param list $actual + */ + private function assertLegacySharesEquals(array $expected, array $actual): void { + $expected = array_map( + function (int $index, IShare $legacyShare) use ($actual): IShare { + $legacyShare->setId($actual[$index]->getId()); + if (($parent = $legacyShare->getParent()) !== null) { + $legacyShare->setParent((int)$actual[$parent]->getId()); + } + + $shareTime = $legacyShare->getShareTime(); + $legacyShare->setShareTime($shareTime->setTime( + (int)$shareTime->format('H'), + (int)$shareTime->format('i'), + (int)$shareTime->format('s'), + )); + + if (($expirationDate = $legacyShare->getExpirationDate()) !== null) { + $legacyShare->setExpirationDate($expirationDate->setTime( + (int)$expirationDate->format('H'), + (int)$expirationDate->format('i'), + (int)$expirationDate->format('s'), + )); + } + + return $legacyShare; + }, + array_keys($expected), + $expected, + ); + + $this->assertEquals(array_map($this->fixLegacyShare(...), $expected), array_map($this->fixLegacyShare(...), $actual)); + } + + private function assertSharesEquals(Share $expected, Share $actual): void { + $this->assertEquals($this->fixShare($expected), $this->fixShare($actual)); + } + + /** + * @param list $expectedLegacyShares + * @param list $actualShares + * @param array> $shareUserStatuses + */ + private function assertSyncingShareToLegacySharesWorks(array $expectedLegacyShares, array $actualShares, array $shareUserStatuses): void { + foreach ($actualShares as $index => $actualShare) { + $this->createShare($actualShare, $shareUserStatuses[$index] ?? []); + } + + $actualLegacyShares = $this->getAllLegacyShares(); + $this->assertCount(count($expectedLegacyShares), $actualLegacyShares); + $this->assertLegacySharesEquals($expectedLegacyShares, $actualLegacyShares); + + $this->clearShares(); + } + + /** + * @param list $expectedShares + * @param list $actualLegacyShares + * @param array> $shareUserStatuses + */ + private function assertSyncingLegacySharesToShareWorks(array $expectedShares, array $actualLegacyShares, array $shareUserStatuses, bool $ignoreRecipientValues): void { + $this->createLegacyShares($actualLegacyShares); + + $actualShares = $this->sharingManager->getShares(new ShareAccessContext(currentUser: $this->user1, overrideChecks: true), null, null, null, null, null, null); + $this->assertCount(count($expectedShares), $actualShares); + foreach ($actualShares as $index => $actualShare) { + $expectedShare = $expectedShares[$index]; + + $this->assertSharesEquals( + new Share( + $actualShare->id, + $expectedShare->owner, + $actualShare->lastUpdated, + $expectedShare->state, + ($shareUserStatuses[$index] ?? [])[$this->user1->getUID()] ?? null, + $expectedShare->sources, + array_map( + fn (int $index, ShareRecipient $recipient): ShareRecipient => new ShareRecipient( + $recipient->class, + $ignoreRecipientValues ? $actualShare->recipients[$index]->value : $recipient->value, + $recipient->instance, + $actualShare->recipients[$index]->secret, + $recipient->initiator, + $recipient->permissions, + ), + array_keys($expectedShare->recipients), + $expectedShare->recipients, + ), + $expectedShare->properties, + $expectedShare->permissions, + ), + $actualShare, + ); + } + + $this->clearShares(); + } + + /** + * @param list $expectedLegacyShares + */ + private function assertSyncingLegacySharesToShareAndBackWorks(array $expectedLegacyShares, bool $delete): void { + $filteredExpectedLegacyShares = $this->createLegacyShares($expectedLegacyShares); + + $actualShares = $this->sharingManager->getShares(new ShareAccessContext(currentUser: $this->user1, overrideChecks: true), null, null, null, null, null, null); + + if ($delete) { + $this->invokePrivate($this->legacySync, 'ignoreAllEvents', [true]); + foreach ($filteredExpectedLegacyShares as &$expectedLegacyShare) { + $this->legacySharingManager->deleteShare($expectedLegacyShare); + $this->invokePrivate($expectedLegacyShare, 'id', [null]); + } + + foreach ($actualShares as $actualShare) { + $this->invokePrivate($this->legacyMapper, 'deleteLegacyMappings', [$actualShare->id]); + } + + $this->invokePrivate($this->legacySync, 'ignoreAllEvents', [false]); + } + + // Just update anything to trigger the syncing + foreach ($actualShares as $actualShare) { + $this->sharingManager->updateShareState(new ShareAccessContext(overrideChecks: true), $actualShare, $actualShare->state); + } + + $actualLegacyShares = $this->getAllLegacyShares(); + $this->assertCount(count($expectedLegacyShares), $actualLegacyShares); + $this->assertLegacySharesEquals($expectedLegacyShares, $actualLegacyShares); + + $this->clearShares(); + } + + /** + * @param list $expectedShares + * @param array> $shareUserStatuses + */ + private function assertSyncingShareToLegacySharesAndBackWorks(array $expectedShares, array $shareUserStatuses, bool $delete, bool $ignoreRecipientValues): void { + foreach ($expectedShares as $index => $expectedShare) { + $this->createShare($expectedShare, $shareUserStatuses[$index] ?? []); + } + + $actualLegacyShares = $this->getAllLegacyShares(); + + if ($delete) { + $this->invokePrivate($this->legacySync, 'ignoreAllEvents', [true]); + foreach ($expectedShares as $expectedShare) { + $this->sharingManager->deleteShare(new ShareAccessContext(overrideChecks: true), $expectedShare); + $this->invokePrivate($this->legacyMapper, 'deleteLegacyMappings', [$expectedShare->id]); + } + + $this->invokePrivate($this->legacySync, 'ignoreAllEvents', [false]); + } + + // Just update anything to trigger the syncing + $this->legacySharingManager->updateShare($actualLegacyShares[0]); + + $actualShares = $this->sharingManager->getShares(new ShareAccessContext(currentUser: $this->user1, overrideChecks: true), null, null, null, null, null, null); + $this->assertCount(count($expectedShares), $actualShares); + foreach ($actualShares as $index => $actualShare) { + $expectedShare = $expectedShares[$index]; + + $this->assertSharesEquals( + new Share( + $actualShare->id, + $expectedShare->owner, + $actualShare->lastUpdated, + $expectedShare->state, + ($shareUserStatuses[$index] ?? [])[$this->user1->getUID()] ?? null, + $expectedShare->sources, + array_map( + fn (int $index, ShareRecipient $recipient): ShareRecipient => new ShareRecipient( + $recipient->class, + $ignoreRecipientValues ? $actualShare->recipients[$index]->value : $recipient->value, + $recipient->instance, + $actualShare->recipients[$index]->secret, + $recipient->initiator, + $recipient->permissions, + ), + array_keys($expectedShare->recipients), + $expectedShare->recipients, + ), + $expectedShare->properties, + $expectedShare->permissions, + ), + $actualShare, + ); + } + + $this->clearShares(); + } + + /** + * @param list $shares + * @param list $legacyShares + * @param array> $shareUserStatuses + */ + private function assertSyncingWorks(array $shares, array $legacyShares, array $shareUserStatuses = [], bool $ignoreRecipientValues = false): void { + $this->assertSyncingShareToLegacySharesWorks(array_map(static fn (IShare $legacyShare): IShare => clone $legacyShare, $legacyShares), $shares, $shareUserStatuses); + $this->assertSyncingLegacySharesToShareWorks($shares, array_map(static fn (IShare $legacyShare): IShare => clone $legacyShare, $legacyShares), $shareUserStatuses, $ignoreRecipientValues); + + $this->assertSyncingShareToLegacySharesAndBackWorks($shares, $shareUserStatuses, false, false); + $this->assertSyncingShareToLegacySharesAndBackWorks($shares, $shareUserStatuses, true, $ignoreRecipientValues); + + $this->assertSyncingLegacySharesToShareAndBackWorks(array_map(static fn (IShare $legacyShare): IShare => clone $legacyShare, $legacyShares), false); + $this->assertSyncingLegacySharesToShareAndBackWorks(array_map(static fn (IShare $legacyShare): IShare => clone $legacyShare, $legacyShares), true); + } + + /** + * @return array{non-empty-string, DateTimeImmutable} + */ + private function generateIdAndCreatedTimestamp(): array { + // Generate a Snowflake ID for the past, to find all codepaths that use the current time instead of the specified time. + $id = $this->snowflakeGenerator->nextId((new DateTimeImmutable())->sub(new DateInterval('PT1M'))); + $created = $this->snowflakeDecoder->decode($id)->getCreatedAt(); + return [$id, $created]; + } + + public function testDeleteShare(): void { + $this->legacySharingManager->createShare( + $this->legacySharingManager->newShare() + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_USER) + ->setSharedWith($this->user1->getUID()) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ); + + $shares = $this->sharingManager->getShares(new ShareAccessContext(overrideChecks: true), null, null, null, null, null, null); + $this->assertCount(1, $shares); + + $this->sharingManager->deleteShare(new ShareAccessContext(overrideChecks: true), $shares[0]); + + $this->assertEmpty(iterator_to_array($this->legacySharingManager->getAllShares())); + } + + public function testDeleteLegacyShare(): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + + $groupRecipient = new ShareRecipient(GroupShareRecipientType::class, $this->group->getGID(), null, $this->sharingManager->generateSecret(), $owner); + + $share = new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [ + new ShareRecipient(UserShareRecipientType::class, $this->user1->getUID(), null, $this->sharingManager->generateSecret(), $owner), + $groupRecipient, + ], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ); + + $this->createShare($share, []); + + $legacyShares = iterator_to_array($this->legacySharingManager->getAllShares()); + $this->assertCount(2, $legacyShares); + + $this->legacySharingManager->deleteShare($legacyShares[0]); + + $legacyShares = iterator_to_array($this->legacySharingManager->getAllShares()); + $this->assertCount(1, $legacyShares); + + $share = $this->sharingBackend->getShare(new ShareAccessContext(overrideChecks: true), $id); + $this->assertEquals([$groupRecipient], $share->recipients); + + $this->legacySharingManager->deleteShare($legacyShares[0]); + $this->assertEmpty($this->sharingManager->getShares(new ShareAccessContext(overrideChecks: true), null, null, null, null, null, null)); + } + + /** + * @return list + */ + public static function dataShareState(): array { + return [ + [ShareState::Active, 1], + [ShareState::Draft, 0], + [ShareState::Deleted, 0], + ]; + } + + #[DataProvider('dataShareState')] + public function testShareStateToLegacy(ShareState $shareState, int $legacySharesCount): void { + $accessContext = new ShareAccessContext($this->owner); + $share = $this->sharingManager->createShare($accessContext); + $share = $this->sharingManager->addShareSource($accessContext, $share, new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())); + $share = $this->sharingManager->addShareRecipient($accessContext, $share, new ShareRecipient(UserShareRecipientType::class, $this->user1->getUID(), null)); + $share = $this->sharingManager->updateSharePermission($accessContext, $share, new SharePermission(NodeReadSharePermissionType::class, true)); + $share = $this->sharingManager->updateSharePermission($accessContext, $share, new SharePermission(NodeDownloadSharePermissionType::class, true)); + + $this->sharingManager->updateShareState($accessContext, $share, $shareState); + + $legacyShares = array_values(iterator_to_array($this->legacySharingManager->getAllShares())); + $this->assertCount($legacySharesCount, $legacyShares); + } + + public function testShareStateFromLegacy(): void { + $this->legacySharingManager->createShare( + $this->legacySharingManager->newShare() + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_USER) + ->setSharedWith($this->user1->getUID()) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ); + + $shares = $this->sharingManager->getShares(new ShareAccessContext(overrideChecks: true), null, null, null, null, null, null); + $this->assertCount(1, $shares); + $this->assertEquals(ShareState::Active, $shares[0]->state); + } + + /** + * @return list + */ + public static function dataShareUserStatus(): array { + return [ + [ShareUserStatus::Pending, IShare::STATUS_PENDING], + [ShareUserStatus::Accepted, IShare::STATUS_ACCEPTED], + [ShareUserStatus::Rejected, IShare::STATUS_REJECTED], + ]; + } + + /** + * @param IShare::STATUS_* $legacyStatus + */ + #[DataProvider('dataShareUserStatus')] + public function testShareUserStatus(ShareUserStatus $shareUserStatus, int $legacyStatus): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(UserShareRecipientType::class, $this->user1->getUID(), null, $this->sharingManager->generateSecret(), $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_USER) + ->setSharedWith($this->user1->getUID()) + ->setSharedWithDisplayName($this->user1->getDisplayName()) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setStatus($legacyStatus) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => $shareUserStatus, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses); + } + + public function testDefaultTarget(): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(UserShareRecipientType::class, $this->user1->getUID(), null, $this->sharingManager->generateSecret(), $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_USER) + ->setSharedWith($this->user1->getUID()) + ->setSharedWithDisplayName($this->user1->getDisplayName()) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setStatus(IShare::STATUS_ACCEPTED) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Accepted, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses); + } + + public function testNonDefaultTarget(): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + + $shares = [new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(UserShareRecipientType::class, $this->user1->getUID(), null, $this->sharingManager->generateSecret(), $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $this->sourceNodeTargetManager->setTarget($this->user1->getUID(), $owner, $this->nodeFolder, '/abc'); + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_USER) + ->setSharedWith($this->user1->getUID()) + ->setSharedWithDisplayName($this->user1->getDisplayName()) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setStatus(IShare::STATUS_ACCEPTED) + ->setTarget('/abc') + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Accepted, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses); + } + + public function testNonDefaultTargetGroup(): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(GroupShareRecipientType::class, $this->group->getGID(), null, $this->sharingManager->generateSecret(), $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $this->sourceNodeTargetManager->setTarget($this->user1->getUID(), $owner, $this->nodeFolder, '/abc'); + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_GROUP) + ->setSharedWith($this->group->getGID()) + ->setSharedWithDisplayName($this->group->getDisplayName()) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setStatus(IShare::STATUS_PENDING) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false), + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_USERGROUP) + ->setSharedWith($this->user1->getUID()) + ->setSharedWithDisplayName($this->user1->getDisplayName()) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setStatus(IShare::STATUS_ACCEPTED) + ->setTarget('/abc') + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setParent(0), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Accepted, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses); + + } + + /** + * @param IShare::STATUS_* $legacyStatus + */ + #[DataProvider('dataShareUserStatus')] + public function testGroupUserStatus(ShareUserStatus $shareUserStatus, int $legacyStatus): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(GroupShareRecipientType::class, $this->group->getGID(), null, $this->sharingManager->generateSecret(), $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_GROUP) + ->setSharedWith($this->group->getGID()) + ->setSharedWithDisplayName($this->group->getDisplayName()) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setStatus(IShare::STATUS_PENDING) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false), + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_USERGROUP) + ->setSharedWith($this->user1->getUID()) + ->setSharedWithDisplayName($this->user1->getDisplayName()) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions($shareUserStatus === ShareUserStatus::Rejected ? 0 : Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setStatus($shareUserStatus === ShareUserStatus::Rejected ? IShare::STATUS_ACCEPTED : $legacyStatus) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setParent(0), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => $shareUserStatus, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses); + } + + public function testReshareToLegacy(): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [ + new ShareRecipient( + UserShareRecipientType::class, + $this->user1->getUID(), + null, + $this->sharingManager->generateSecret(), + $owner, + ), + new ShareRecipient( + UserShareRecipientType::class, + $this->user2->getUID(), + null, + $this->sharingManager->generateSecret(), + new ShareUser($this->user1->getUID(), null), + [ + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, false), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + ], + ), + ], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, true), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_USER) + ->setSharedWith($this->user1->getUID()) + ->setSharedWithDisplayName($this->user1->getDisplayName()) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ | Constants::PERMISSION_SHARE) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setStatus(IShare::STATUS_ACCEPTED) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false), + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_USER) + ->setSharedWith($this->user2->getUID()) + ->setSharedWithDisplayName($this->user2->getDisplayName()) + ->setSharedBy($this->user1->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', false)) + ->setStatus(IShare::STATUS_ACCEPTED) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Accepted, + $this->user2->getUID() => ShareUserStatus::Accepted, + ], + ]; + + // In this direction we can sync reshares correctly, because all information is contained in the share. + $this->assertSyncingShareToLegacySharesWorks(array_map(static fn (IShare $legacyShare): IShare => clone $legacyShare, $legacyShares), $shares, $shareUserStatuses); + $this->assertSyncingShareToLegacySharesAndBackWorks($shares, $shareUserStatuses, false, false); + // Can't test syncing forth and back with deletion, because the information for linking reshares is missing in legacy shares. + } + + public function testReshareFromLegacy(): void { + [$id1, $created1] = $this->generateIdAndCreatedTimestamp(); + [$id2, $created2] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + + $shares = [ + new Share( + $id1, + $owner, + $created1, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [ + new ShareRecipient(UserShareRecipientType::class, $this->user1->getUID(), null, $this->sharingManager->generateSecret(), $owner), + ], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, true), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + new Share( + $id2, + $owner, + $created2, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [ + new ShareRecipient(UserShareRecipientType::class, $this->user2->getUID(), null, $this->sharingManager->generateSecret(), new ShareUser($this->user1->getUID(), null)), + ], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, false), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_USER) + ->setSharedWith($this->user1->getUID()) + ->setSharedWithDisplayName($this->user1->getDisplayName()) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ | Constants::PERMISSION_SHARE) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setStatus(IShare::STATUS_ACCEPTED) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created1)) + ->setMailSend(false), + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_USER) + ->setSharedWith($this->user2->getUID()) + ->setSharedWithDisplayName($this->user2->getDisplayName()) + ->setSharedBy($this->user1->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', false)) + ->setStatus(IShare::STATUS_ACCEPTED) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created2)) + ->setMailSend(false), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Accepted, + ], + [ + $this->user1->getUID() => ShareUserStatus::Pending, + ], + ]; + + // In this direction we can't sync reshares correctly, because the information for linking reshares is missing in legacy shares. + $this->assertSyncingLegacySharesToShareWorks($shares, array_map(static fn (IShare $legacyShare): IShare => clone $legacyShare, $legacyShares), $shareUserStatuses, false); + $this->assertSyncingLegacySharesToShareAndBackWorks(array_map(static fn (IShare $legacyShare): IShare => clone $legacyShare, $legacyShares), false); + $this->assertSyncingLegacySharesToShareAndBackWorks(array_map(static fn (IShare $legacyShare): IShare => clone $legacyShare, $legacyShares), true); + } + + /** + * @return list + */ + public static function dataDownloadPermission(): array { + return [ + [true], + [false], + ]; + } + + #[DataProvider('dataDownloadPermission')] + public function testDownloadPermissionPrivateShare(bool $enabled): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(UserShareRecipientType::class, $this->user1->getUID(), null, $this->sharingManager->generateSecret(), $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, $enabled), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_USER) + ->setSharedWith($this->user1->getUID()) + ->setSharedWithDisplayName($this->user1->getDisplayName()) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes( + (new ShareAttributes()) + ->setAttribute('permissions', 'download', $enabled), + ) + ->setStatus(IShare::STATUS_ACCEPTED) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setHideDownload(false), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Accepted, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses); + } + + #[DataProvider('dataDownloadPermission')] + public function testEmailShareWithDownloadPermission(bool $enabled): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + $secret = $this->sharingManager->generateSecret(); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(EmailShareRecipientType::class, 'test@example.com', null, $secret, $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + PasswordSharePropertyType::class => new ShareProperty(PasswordSharePropertyType::class, null), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, $enabled), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocMailShare') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_EMAIL) + ->setSharedWith('test@example.com') + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes( + (new ShareAttributes()) + ->setAttribute('permissions', 'download', $enabled), + ) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setToken($secret) + ->setHideDownload(!$enabled), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Pending, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses); + } + + #[DataProvider('dataDownloadPermission')] + public function testLinkShareWithDownloadPermission(bool $enabled): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + $secret = $this->sharingManager->generateSecret(); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(TokenShareRecipientType::class, $this->sharingManager->generateSecret(), null, $secret, $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + PasswordSharePropertyType::class => new ShareProperty(PasswordSharePropertyType::class, null), + LabelSharePropertyType::class => new ShareProperty(LabelSharePropertyType::class, null), + NodeGridViewSharePropertyType::class => new ShareProperty(NodeGridViewSharePropertyType::class, 'false'), + NodeNicknameSharePropertyType::class => new ShareProperty(NodeNicknameSharePropertyType::class, 'false'), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, $enabled), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_LINK) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes( + (new ShareAttributes()) + ->setAttribute('permissions', 'download', $enabled), + ) + ->setStatus(IShare::STATUS_PENDING) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setToken($secret) + ->setHideDownload(!$enabled), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Pending, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses, true); + } + + public function testEmailShareWithPassword(): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + $secret = $this->sharingManager->generateSecret(); + $passwordHash = Server::get(IHasher::class)->hash('123'); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(EmailShareRecipientType::class, 'test@example.com', null, $secret, $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + PasswordSharePropertyType::class => new ShareProperty(PasswordSharePropertyType::class, $passwordHash), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocMailShare') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_EMAIL) + ->setSharedWith('test@example.com') + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setToken($secret) + ->setPasswordHash($passwordHash), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Pending, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses); + } + + public function testLinkShare(): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + $secret = $this->sharingManager->generateSecret(); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(TokenShareRecipientType::class, $this->sharingManager->generateSecret(), null, $secret, $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + PasswordSharePropertyType::class => new ShareProperty(PasswordSharePropertyType::class, null), + LabelSharePropertyType::class => new ShareProperty(LabelSharePropertyType::class, null), + NodeGridViewSharePropertyType::class => new ShareProperty(NodeGridViewSharePropertyType::class, 'false'), + NodeNicknameSharePropertyType::class => new ShareProperty(NodeNicknameSharePropertyType::class, 'false'), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_LINK) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setStatus(IShare::STATUS_PENDING) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setToken($secret) + ->setHideDownload(false), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Pending, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses, true); + } + + public function testLinkShareWithPassword(): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + $secret = $this->sharingManager->generateSecret(); + $passwordHash = Server::get(IHasher::class)->hash('123'); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(TokenShareRecipientType::class, $this->sharingManager->generateSecret(), null, $secret, $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + PasswordSharePropertyType::class => new ShareProperty(PasswordSharePropertyType::class, $passwordHash), + LabelSharePropertyType::class => new ShareProperty(LabelSharePropertyType::class, null), + NodeGridViewSharePropertyType::class => new ShareProperty(NodeGridViewSharePropertyType::class, 'false'), + NodeNicknameSharePropertyType::class => new ShareProperty(NodeNicknameSharePropertyType::class, 'false'), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_LINK) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setStatus(IShare::STATUS_PENDING) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setToken($secret) + ->setHideDownload(false) + ->setPasswordHash($passwordHash), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Pending, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses, true); + } + + /** + * @return list + */ + public static function dataGridView(): array { + return [ + [true], + [false], + ]; + } + + #[DataProvider('dataGridView')] + public function testGridView(bool $enabled): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + $secret = $this->sharingManager->generateSecret(); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(TokenShareRecipientType::class, $this->sharingManager->generateSecret(), null, $secret, $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + PasswordSharePropertyType::class => new ShareProperty(PasswordSharePropertyType::class, null), + LabelSharePropertyType::class => new ShareProperty(LabelSharePropertyType::class, null), + NodeGridViewSharePropertyType::class => new ShareProperty(NodeGridViewSharePropertyType::class, $enabled ? 'true' : 'false'), + NodeNicknameSharePropertyType::class => new ShareProperty(NodeNicknameSharePropertyType::class, 'false'), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $attributes = new ShareAttributes(); + $attributes->setAttribute('permissions', 'download', true); + if ($enabled) { + $attributes->setAttribute('config', 'grid_view', true); + } + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_LINK) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes($attributes) + ->setStatus(IShare::STATUS_PENDING) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setToken($secret) + ->setHideDownload(false), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Pending, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses, true); + } + + public function testExpirationDate(): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + $secret = $this->sharingManager->generateSecret(); + $expirationDate = $created->add(new DateInterval('PT1H')); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(TokenShareRecipientType::class, $this->sharingManager->generateSecret(), null, $secret, $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, $expirationDate->format(DateTimeInterface::ATOM)), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + PasswordSharePropertyType::class => new ShareProperty(PasswordSharePropertyType::class, null), + LabelSharePropertyType::class => new ShareProperty(LabelSharePropertyType::class, null), + NodeGridViewSharePropertyType::class => new ShareProperty(NodeGridViewSharePropertyType::class, 'false'), + NodeNicknameSharePropertyType::class => new ShareProperty(NodeNicknameSharePropertyType::class, 'false'), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_LINK) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setStatus(IShare::STATUS_PENDING) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setToken($secret) + ->setHideDownload(false) + ->setExpirationDate(DateTime::createFromImmutable($expirationDate)), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Pending, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses, true); + } + + public function testLabel(): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + $secret = $this->sharingManager->generateSecret(); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(TokenShareRecipientType::class, $this->sharingManager->generateSecret(), null, $secret, $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + PasswordSharePropertyType::class => new ShareProperty(PasswordSharePropertyType::class, null), + LabelSharePropertyType::class => new ShareProperty(LabelSharePropertyType::class, 'label'), + NodeGridViewSharePropertyType::class => new ShareProperty(NodeGridViewSharePropertyType::class, 'false'), + NodeNicknameSharePropertyType::class => new ShareProperty(NodeNicknameSharePropertyType::class, 'false'), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_LINK) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setStatus(IShare::STATUS_PENDING) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setToken($secret) + ->setHideDownload(false) + ->setLabel('label'), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Pending, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses, true); + } + + public function testNote(): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + $secret = $this->sharingManager->generateSecret(); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(TokenShareRecipientType::class, $this->sharingManager->generateSecret(), null, $secret, $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, 'note'), + PasswordSharePropertyType::class => new ShareProperty(PasswordSharePropertyType::class, null), + LabelSharePropertyType::class => new ShareProperty(LabelSharePropertyType::class, null), + NodeGridViewSharePropertyType::class => new ShareProperty(NodeGridViewSharePropertyType::class, 'false'), + NodeNicknameSharePropertyType::class => new ShareProperty(NodeNicknameSharePropertyType::class, 'false'), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_LINK) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setStatus(IShare::STATUS_PENDING) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setToken($secret) + ->setHideDownload(false) + ->setNote('note'), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Pending, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses, true); + } + + public function testFileRequestWithoutEmails(): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + $secret = $this->sharingManager->generateSecret(); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(TokenShareRecipientType::class, $this->sharingManager->generateSecret(), null, $secret, $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + PasswordSharePropertyType::class => new ShareProperty(PasswordSharePropertyType::class, null), + LabelSharePropertyType::class => new ShareProperty(LabelSharePropertyType::class, null), + NodeGridViewSharePropertyType::class => new ShareProperty(NodeGridViewSharePropertyType::class, 'false'), + NodeNicknameSharePropertyType::class => new ShareProperty(NodeNicknameSharePropertyType::class, 'true'), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, false), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, false), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, true), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocMailShare') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_EMAIL) + ->setSharedWith('') + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_CREATE) + ->setAttributes( + (new ShareAttributes()) + ->setAttribute('permissions', 'download', false) + ->setAttribute('fileRequest', 'enabled', true) + ->setAttribute('shareWith', 'emails', []), + ) + ->setStatus(IShare::STATUS_PENDING) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setToken($secret) + ->setHideDownload(true), + ]; + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Pending, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses, true); + } + + public function testFileRequestWithEmails(): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + $secret1 = $this->sharingManager->generateSecret(); + $secret2 = $this->sharingManager->generateSecret(); + $secret3 = $this->sharingManager->generateSecret(); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [ + new ShareRecipient(TokenShareRecipientType::class, $this->sharingManager->generateSecret(), null, $secret1, $owner), + new ShareRecipient(EmailShareRecipientType::class, 'example@example.com', null, $secret2, $owner), + new ShareRecipient(EmailShareRecipientType::class, 'example@example.org', null, $secret3, $owner), + ], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + PasswordSharePropertyType::class => new ShareProperty(PasswordSharePropertyType::class, null), + LabelSharePropertyType::class => new ShareProperty(LabelSharePropertyType::class, null), + NodeGridViewSharePropertyType::class => new ShareProperty(NodeGridViewSharePropertyType::class, 'false'), + NodeNicknameSharePropertyType::class => new ShareProperty(NodeNicknameSharePropertyType::class, 'true'), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, false), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, false), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, true), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocMailShare') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_EMAIL) + ->setSharedWith('') + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_CREATE) + ->setAttributes( + (new ShareAttributes()) + ->setAttribute('permissions', 'download', false) + + ->setAttribute('fileRequest', 'enabled', true) + ->setAttribute('shareWith', 'emails', ['example@example.com', 'example@example.org']), + ) + ->setStatus(IShare::STATUS_PENDING) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setToken($secret1) + ->setHideDownload(true), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Pending, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses, true); + } + + public function testFileDrop(): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + $secret = $this->sharingManager->generateSecret(); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(TokenShareRecipientType::class, $this->sharingManager->generateSecret(), null, $secret, $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + PasswordSharePropertyType::class => new ShareProperty(PasswordSharePropertyType::class, null), + LabelSharePropertyType::class => new ShareProperty(LabelSharePropertyType::class, null), + NodeGridViewSharePropertyType::class => new ShareProperty(NodeGridViewSharePropertyType::class, 'false'), + NodeNicknameSharePropertyType::class => new ShareProperty(NodeNicknameSharePropertyType::class, 'false'), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, false), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, true), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocinternal') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_LINK) + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_CREATE) + ->setAttributes((new ShareAttributes())->setAttribute('permissions', 'download', true)) + ->setStatus(IShare::STATUS_PENDING) + ->setTarget('/' . $this->nodeFolder->getName()) + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setToken($secret) + ->setHideDownload(false), + ]; + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Pending, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses, true); + } + + public function testRemoteSharedWithUser(): void { + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + $secret = $this->sharingManager->generateSecret(); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(UserShareRecipientType::class, 'remote', 'https://example.com', $secret, $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocFederatedSharing') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_REMOTE) + ->setSharedWith('remote@https://example.com') + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setStatus(IShare::STATUS_PENDING) + ->setTarget('') + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setToken($secret), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Pending, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses); + } + + /** @psalm-suppress DeprecatedMethod The configs are not migrated to IAppConfig, so using deprecated IConfig is required for now. */ + public function testRemoteSharedWithGroup(): void { + Server::get(IConfig::class)->setAppValue('files_sharing', 'outgoing_server2server_group_share_enabled', 'yes'); + + [$id, $created] = $this->generateIdAndCreatedTimestamp(); + $owner = new ShareUser($this->owner->getUID(), null); + $secret = $this->sharingManager->generateSecret(); + + $shares = [ + new Share( + $id, + $owner, + $created, + ShareState::Active, + null, + [new ShareSource(NodeShareSourceType::class, (string)$this->nodeFolder->getId())], + [new ShareRecipient(GroupShareRecipientType::class, 'remote', 'https://example.com', $secret, $owner)], + [ + ExpirationDateSharePropertyType::class => new ShareProperty(ExpirationDateSharePropertyType::class, null), + NoteSharePropertyType::class => new ShareProperty(NoteSharePropertyType::class, null), + ], + [ + NodeReadSharePermissionType::class => new SharePermission(NodeReadSharePermissionType::class, true), + NodeDownloadSharePermissionType::class => new SharePermission(NodeDownloadSharePermissionType::class, true), + ReshareSharePermissionType::class => new SharePermission(ReshareSharePermissionType::class, false), + NodeCreateSharePermissionType::class => new SharePermission(NodeCreateSharePermissionType::class, false), + NodeUpdateSharePermissionType::class => new SharePermission(NodeUpdateSharePermissionType::class, false), + NodeDeleteSharePermissionType::class => new SharePermission(NodeDeleteSharePermissionType::class, false), + ], + ), + ]; + + $legacyShares = [ + $this->legacySharingManager->newShare() + ->setProviderId('ocFederatedSharing') + ->setNode($this->nodeFolder) + ->setShareType(IShare::TYPE_REMOTE_GROUP) + ->setSharedWith('remote@https://example.com') + ->setSharedBy($this->owner->getUID()) + ->setShareOwner($this->owner->getUID()) + ->setPermissions(Constants::PERMISSION_READ) + ->setStatus(IShare::STATUS_PENDING) + ->setTarget('') + ->setShareTime(DateTime::createFromImmutable($created)) + ->setMailSend(false) + ->setToken($secret), + ]; + + $shareUserStatuses = [ + [ + $this->user1->getUID() => ShareUserStatus::Pending, + ], + ]; + + $this->assertSyncingWorks($shares, $legacyShares, $shareUserStatuses); + + Server::get(IConfig::class)->deleteAppValue('files_sharing', 'outgoing_server2server_group_share_enabled'); + } + + // Remote share owner and shared by are explicitly not tested, because the legacy sync doesn't handle them. +} From 54ed407a7d7131e7d484b9061adbb82142a86160 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 23 Sep 2026 13:56:51 +0200 Subject: [PATCH 22/23] test(integration): Enable Unified Sharing legacy sync validation Signed-off-by: provokateurin --- build/integration/run.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build/integration/run.sh b/build/integration/run.sh index 4ff808b5056b9..7c376c1c34330 100755 --- a/build/integration/run.sh +++ b/build/integration/run.sh @@ -36,6 +36,9 @@ if [ "$INSTALLED" == "true" ]; then $OCC config:system:set sharing.federation.allowSelfSignedCertificates --value true --type bool # Allow creating users with dummy passwords $OCC app:disable password_policy + # Enable Unified Sharing legacy sync validation + $OCC config:system:set --type=boolean --value=true sharing.unified_api_enable + $OCC config:system:set --type=boolean --value=true unified_sharing.legacy_sync.validation.enable else if [ "$SCENARIO_TO_RUN" != "setup_features/setup.feature" ]; then echo "Nextcloud instance needs to be installed" >&2 From de076134e31044868abe2cd1687890aadaf4e9cf Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 13 Oct 2026 13:51:42 +0200 Subject: [PATCH 23/23] chore(Sharing): Remove outdated TODOs --- apps/sharing/lib/Controller/ApiV1Controller.php | 2 -- lib/private/Sharing/SharingManager.php | 2 -- 2 files changed, 4 deletions(-) diff --git a/apps/sharing/lib/Controller/ApiV1Controller.php b/apps/sharing/lib/Controller/ApiV1Controller.php index c37854c36bc06..06e9759d8e64a 100644 --- a/apps/sharing/lib/Controller/ApiV1Controller.php +++ b/apps/sharing/lib/Controller/ApiV1Controller.php @@ -50,8 +50,6 @@ use RuntimeException; use ValueError; -// TODO: Add "recipient suggestions" endpoint - /** * @psalm-import-type SharingShare from ResponseDefinitions * @psalm-import-type SharingRecipient from ResponseDefinitions diff --git a/lib/private/Sharing/SharingManager.php b/lib/private/Sharing/SharingManager.php index 17183308d88bf..4886a610f5ccc 100644 --- a/lib/private/Sharing/SharingManager.php +++ b/lib/private/Sharing/SharingManager.php @@ -53,9 +53,7 @@ use Random\Randomizer; use RuntimeException; -// TODO: Test sharing to federated users, groups and circles // TODO: Implement share transfers -// TODO: Cache share owner /** * @psalm-import-type SharingShare from Share