From 8d291d7b63123fc8158182db19bc83b2ee86e227 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 30 Sep 2026 10:00:32 +0200 Subject: [PATCH 01/22] refactor(files_sharing): Set expiration date time to 23:59:59 only in the controller Signed-off-by: provokateurin --- apps/files_sharing/lib/Controller/ShareAPIController.php | 2 ++ lib/private/Share20/Manager.php | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/files_sharing/lib/Controller/ShareAPIController.php b/apps/files_sharing/lib/Controller/ShareAPIController.php index 854c92aad0060..3a667550839f8 100644 --- a/apps/files_sharing/lib/Controller/ShareAPIController.php +++ b/apps/files_sharing/lib/Controller/ShareAPIController.php @@ -699,6 +699,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); @@ -1377,6 +1378,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/lib/private/Share20/Manager.php b/lib/private/Share20/Manager.php index 1971abbdb6508..e2198dc91f0bb 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -300,7 +300,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); From 1d635eac42ddca6cb7912164f5b9394e13f0ce99 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 2 Sep 2026 09:40:44 +0200 Subject: [PATCH 02/22] 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 6bd5479f4c07bc926542a1dc6d1bd9d07af175b1 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 2 Sep 2026 10:38:43 +0200 Subject: [PATCH 03/22] 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 | 12 +++++- .../Share/Events/ShareRestoredEvent.php | 37 +++++++++++++++++++ lib/public/Share/Events/ShareUpdatedEvent.php | 37 +++++++++++++++++++ psalm-strict.xml | 2 + 7 files changed, 93 insertions(+), 1 deletion(-) 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 b3b5b63fb3a1e..9332d10d69665 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -949,7 +949,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 35c872fd7527d..d9f328cb84d9f 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -990,7 +990,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 e2198dc91f0bb..4a1dfe2e2296e 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -67,6 +67,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; @@ -761,6 +763,8 @@ public function updateShare(IShare $share, bool $onlyValid = true): IShare { $share = $provider->update($share); } + $this->dispatcher->dispatchTyped(new ShareUpdatedEvent($share)); + if ($expirationDateUpdated === true) { \OC_Hook::emit(Share::class, 'post_set_expiration_date', [ 'itemType' => $share->getNode() instanceof File ? 'file' : 'folder', @@ -1079,7 +1083,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] @@ -1500,6 +1508,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 @@ + + From 8c623ef07c18f712029dcb033ab246af686317b2 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 23 Sep 2026 14:23:53 +0200 Subject: [PATCH 04/22] 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 4a1dfe2e2296e..1d89b1e9e1846 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -637,7 +637,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)) { @@ -763,7 +765,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 ($expirationDateUpdated === true) { \OC_Hook::emit(Share::class, 'post_set_expiration_date', [ @@ -818,6 +822,7 @@ public function acceptShare(IShare $share, string $recipientId): IShare { $event = new ShareAcceptedEvent($share); $this->dispatchEvent($event, 'share accepted'); + $share = $event->getShare(); return $share; } @@ -1083,11 +1088,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] @@ -1118,11 +1125,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] @@ -1509,7 +1518,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 b47f7a84bf32bdbea1343c05a73afe9636186432 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 2 Sep 2026 11:08:09 +0200 Subject: [PATCH 05/22] 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/SharingManager.php | 11 ++++++- .../Sharing/Event/SharesDeletedEvent.php | 31 +++++++++++++++++++ .../Sharing/Event/SharesUpdatedEvent.php | 31 +++++++++++++++++++ 5 files changed, 76 insertions(+), 1 deletion(-) 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 9332d10d69665..5484e49152a95 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -45,6 +45,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 d9f328cb84d9f..57adfc3613820 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -86,6 +86,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/SharingManager.php b/lib/private/Sharing/SharingManager.php index a6d36ca28e782..4fdfc3e766f8a 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; @@ -192,7 +194,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] @@ -839,6 +844,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])); } /** @@ -1072,6 +1079,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..ea70e39db27ca --- /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..4bb712d6bb764 --- /dev/null +++ b/lib/unstable/Sharing/Event/SharesUpdatedEvent.php @@ -0,0 +1,31 @@ + $shareIds + * @experimental 35.0.0 + */ + public function __construct( + public readonly array $shareIds, + ) { + parent::__construct(); + } +} From 41d9b262ed3398b2580916dbd591074c3a3e2f65 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 23 Sep 2026 14:35:35 +0200 Subject: [PATCH 06/22] 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 71e492ad27813ed52a00feded00f3fbe953a3bca Mon Sep 17 00:00:00 2001 From: provokateurin Date: Thu, 10 Sep 2026 11:06:35 +0200 Subject: [PATCH 07/22] 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 21af94c8588ffa4ecd11359351ac54766dd64e1e Mon Sep 17 00:00:00 2001 From: provokateurin Date: Thu, 10 Sep 2026 11:11:09 +0200 Subject: [PATCH 08/22] feat(IShareRecipientType): Add getUsers() Signed-off-by: provokateurin --- core/Sharing/Recipient/EmailShareRecipientType.php | 5 +++++ core/Sharing/Recipient/GroupShareRecipientType.php | 5 +++++ core/Sharing/Recipient/TeamShareRecipientType.php | 5 +++++ core/Sharing/Recipient/TokenShareRecipientType.php | 5 +++++ core/Sharing/Recipient/UserShareRecipientType.php | 5 +++++ lib/unstable/Sharing/Recipient/IShareRecipientType.php | 8 ++++++++ .../Sharing/Recipient/GroupShareRecipientTypeTest.php | 8 ++++++-- .../Core/Sharing/Recipient/TeamShareRecipientTypeTest.php | 8 +++++++- .../Core/Sharing/Recipient/UserShareRecipientTypeTest.php | 8 ++++++-- tests/lib/Sharing/TestShareRecipientType1.php | 5 +++++ tests/lib/Sharing/TestShareRecipientTypeArguments.php | 5 +++++ tests/lib/Sharing/TestShareRecipientTypePublicSecret.php | 5 +++++ 12 files changed, 67 insertions(+), 5 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 53c1de0629961..6074d2738e078 100644 --- a/core/Sharing/Recipient/GroupShareRecipientType.php +++ b/core/Sharing/Recipient/GroupShareRecipientType.php @@ -64,6 +64,11 @@ public function getRecipients(?IUser $currentUser, mixed $arguments): array { return $this->groupManager->getUserGroupIds($currentUser); } + #[\Override] + public function getUsers(string $recipient): array { + return array_keys($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 61a02fded937b..734362badbb08 100644 --- a/core/Sharing/Recipient/TeamShareRecipientType.php +++ b/core/Sharing/Recipient/TeamShareRecipientType.php @@ -69,6 +69,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_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 0b4010136a4fb..633e114c15ea3 100644 --- a/core/Sharing/Recipient/UserShareRecipientType.php +++ b/core/Sharing/Recipient/UserShareRecipientType.php @@ -63,6 +63,11 @@ public function getRecipients(?IUser $currentUser, mixed $arguments): array { return [$currentUser->getUID()]; } + #[\Override] + public function getUsers(string $recipient): array { + return [$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..40ab615dcca81 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..b9dcb3e4d2243 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->getUID()], $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..dab77698373e8 100644 --- a/tests/Core/Sharing/Recipient/TeamShareRecipientTypeTest.php +++ b/tests/Core/Sharing/Recipient/TeamShareRecipientTypeTest.php @@ -125,10 +125,16 @@ 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->getUID()], $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 🤷‍♀️'); diff --git a/tests/Core/Sharing/Recipient/UserShareRecipientTypeTest.php b/tests/Core/Sharing/Recipient/UserShareRecipientTypeTest.php index 20978861bb5d6..f7e4e2aa7f3e9 100644 --- a/tests/Core/Sharing/Recipient/UserShareRecipientTypeTest.php +++ b/tests/Core/Sharing/Recipient/UserShareRecipientTypeTest.php @@ -95,8 +95,12 @@ 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 { + $this->assertEquals([$this->user1->getUID()], $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 7968c105c885dc663f4a86e078bd5983829ebce6 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Thu, 10 Sep 2026 13:41:04 +0200 Subject: [PATCH 09/22] 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 | 4 + .../Version35000Date20260910082613.php | 37 +++++ .../lib/Sharing/SharesUpdatedListener.php | 76 +++++++++ .../lib/Sharing/SourceNodeTargetManager.php | 147 ++++++++++++++++++ .../Sharing/SharesUpdatedListenerTest.php | 108 +++++++++++++ .../Sharing/SourceNodeTargetManagerTest.php | 104 +++++++++++++ build/rector-strict.php | 1 + psalm-strict.xml | 1 + 11 files changed, 485 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 3fd82c774c240..be3b0c8e09567 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/apps/files/composer/composer/autoload_classmap.php @@ -86,6 +86,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\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php', 'OCA\\Files\\Search\\FileAccountScopedSearchProvider' => $baseDir . '/../lib/Search/FileAccountScopedSearchProvider.php', @@ -106,6 +107,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 5c8eb91c4c2e5..180e99bedf855 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/apps/files/composer/composer/autoload_static.php @@ -101,6 +101,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\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php', 'OCA\\Files\\Search\\FileAccountScopedSearchProvider' => __DIR__ . '/..' . '/../lib/Search/FileAccountScopedSearchProvider.php', @@ -121,6 +122,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 56fcc6ce2c4aa..c967a392a1d8b 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; @@ -43,6 +44,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 OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -134,6 +136,8 @@ 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(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..f03e519e42dab --- /dev/null +++ b/apps/files/lib/Migration/Version35000Date20260910082613.php @@ -0,0 +1,37 @@ +createTable('sharing_source_node_target'); + $sourceNodeTargetTable->addColumn('user_id', Types::TEXT, ['length' => 64]); + $sourceNodeTargetTable->addColumn('source_instance', Types::STRING, ['length' => 128, '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', '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..05a604b0d2438 --- /dev/null +++ b/apps/files/lib/Sharing/SharesUpdatedListener.php @@ -0,0 +1,76 @@ + + */ +final readonly class SharesUpdatedListener implements IEventListener { + public function __construct( + private ISharingManager $manager, + private ISharingRegistry $registry, + private SourceNodeTargetManager $sourceNodeTargetManager, + ) { + } + + #[Override] + public function handle(Event $event): void { + foreach ($event->shareIds as $shareId) { + $share = $this->manager->getShare(new ShareAccessContext(overrideChecks: true), $shareId); + if ($share->state !== ShareState::Active) { + continue; + } + + foreach ($share->sources as $source) { + if ($source->class !== NodeShareSourceType::class) { + continue; + } + + $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 $userId) { + $userIds[$userId] = true; + } + } + + foreach (array_keys($userIds) as $userId) { + try { + if ($this->sourceNodeTargetManager->getTarget($userId, $share->owner, (int)$source->value) === null) { + $this->sourceNodeTargetManager->createDefaultTarget($userId, $share->owner, (int)$source->value); + } + } catch (NotFoundException) { + // The same exception will happen for the other users, because the source node is the same, so we can just stop here. + break; + } + } + } + } + } +} diff --git a/apps/files/lib/Sharing/SourceNodeTargetManager.php b/apps/files/lib/Sharing/SourceNodeTargetManager.php new file mode 100644 index 0000000000000..affe038d765fe --- /dev/null +++ b/apps/files/lib/Sharing/SourceNodeTargetManager.php @@ -0,0 +1,147 @@ + $userIds + * @return array + */ + private function getTargetsInternal(array $userIds, ShareUser $owner, int $sourceNodeId): 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') + : $qb->expr()->eq('source_instance', $qb->createNamedParameter($owner->instance)) + ) + ->andWhere($qb->expr()->eq('source_node_id', $qb->createNamedParameter($sourceNodeId, 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, int $sourceNodeId): string { + if ($owner->instance !== null) { + throw new RuntimeException('Federation is not supported yet.'); + } + + $ownerUserFolder = $this->rootFolder->getUserFolder($owner->userId); + $node = $ownerUserFolder->getFirstNodeById($sourceNodeId); + if (!$node instanceof Node) { + throw new NotFoundException(); + } + + $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 . '/' . $node->getName()); + + $qb = $this->dbConnection->getQueryBuilder(); + $qb + ->insert('sharing_source_node_target') + ->values([ + 'user_id' => $qb->createNamedParameter($userId), + 'source_instance' => $qb->createNamedParameter($owner->instance), + 'source_node_id' => $qb->createNamedParameter($sourceNodeId), + 'target' => $qb->createNamedParameter($target), + ]) + ->executeStatement(); + + return $target; + } + + public function getTarget(string $userId, ShareUser $owner, int $sourceNodeId): ?string { + return $this->getTargetsInternal([$userId], $owner, $sourceNodeId)[$userId] ?? null; + } + + /** + * @param list $userIds + * @return array + */ + public function getTargets(array $userIds, ShareUser $owner, int $sourceNodeId): array { + $targets = $this->getTargetsInternal($userIds, $owner, $sourceNodeId); + foreach ($userIds as $userId) { + if (isset($targets[$userId])) { + continue; + } + + $targets[$userId] = $this->createDefaultTarget($userId, $owner, $sourceNodeId); + } + + return $targets; + } + + public function setTarget(string $userId, ShareUser $owner, int $sourceNodeId, 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') + : $qb->expr()->eq('source_instance', $qb->createNamedParameter($owner->instance)) + ) + ->andWhere($qb->expr()->eq('source_node_id', $qb->createNamedParameter($sourceNodeId, 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' => $qb->createNamedParameter($owner->instance), + 'source_node_id' => $qb->createNamedParameter($sourceNodeId), + '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..7ca992598119d --- /dev/null +++ b/apps/files/tests/Sharing/SharesUpdatedListenerTest.php @@ -0,0 +1,108 @@ +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); + } + + #[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(); + + 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->getId()]); + } + + 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..1579987077c8e --- /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->getId())); + } + + 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->getId())); + + $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->getId())); + + $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->getId())); + + $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->getId())); + + $this->manager->setTarget($this->user->getUID(), new ShareUser($this->owner->getUID(), null), $this->node->getId(), '/bar.txt'); + $this->assertEquals('/bar.txt', $this->manager->getTarget($this->user->getUID(), new ShareUser($this->owner->getUID(), null), $this->node->getId())); + + $this->manager->setTarget($this->user->getUID(), new ShareUser($this->owner->getUID(), null), $this->node->getId(), '/baz.txt'); + $this->assertEquals('/baz.txt', $this->manager->getTarget($this->user->getUID(), new ShareUser($this->owner->getUID(), null), $this->node->getId())); + } +} 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 91c5008d8562ec4aa2685dc037abc6d2010434d8 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 23 Sep 2026 13:59:25 +0200 Subject: [PATCH 10/22] feat(APasswordSharePropertyType): Only hash the new value if it's not a hash already Signed-off-by: provokateurin --- .../Sharing/Property/APasswordSharePropertyType.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/unstable/Sharing/Property/APasswordSharePropertyType.php b/lib/unstable/Sharing/Property/APasswordSharePropertyType.php index 4ff5ba8a62eb3..a137b7b36290c 100644 --- a/lib/unstable/Sharing/Property/APasswordSharePropertyType.php +++ b/lib/unstable/Sharing/Property/APasswordSharePropertyType.php @@ -73,6 +73,13 @@ public function modifyValueOnSave(?string $oldValue, ?string $newValue): ?string return $oldValue; } + // A password that looks like a password hash would pass here, but that is hard to create by accident. + // Intentionally storing a "plain" password in the DB can't be abused by attackers. + // TODO: Remove this, once the legacy sync is removed. + if ($this->getHasher()->validate($newValue)) { + return $newValue; + } + return $this->getHasher()->hash($newValue); } From 839e37587e1e1fcff9f7ac51739e65263c0de2dc Mon Sep 17 00:00:00 2001 From: provokateurin Date: Mon, 21 Sep 2026 16:26:50 +0200 Subject: [PATCH 11/22] 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 1e3bcc1ee321b..024a1635f699b 100644 --- a/lib/private/Sharing/SharingBackend.php +++ b/lib/private/Sharing/SharingBackend.php @@ -1141,12 +1141,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); } } @@ -1314,9 +1308,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 4fdfc3e766f8a..6d573006ea3c5 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; @@ -648,9 +647,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 a137b7b36290c..58ecb1605a90b 100644 --- a/lib/unstable/Sharing/Property/APasswordSharePropertyType.php +++ b/lib/unstable/Sharing/Property/APasswordSharePropertyType.php @@ -87,7 +87,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 df4ec3fe5cd35..799b4842510ab 100644 --- a/tests/lib/Sharing/AbstractSharingManagerTests.php +++ b/tests/lib/Sharing/AbstractSharingManagerTests.php @@ -1748,7 +1748,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); @@ -1786,7 +1786,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']); @@ -1820,7 +1820,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']); @@ -1850,12 +1850,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([ @@ -1877,9 +1877,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 1f70c33515452b0b7aeeffef8c57ec2519499e7f Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 23 Sep 2026 14:00:34 +0200 Subject: [PATCH 12/22] 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 417a32624297caa5879bc6679e3bebed1480b881 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 23 Sep 2026 14:40:21 +0200 Subject: [PATCH 13/22] 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 024a1635f699b..310c597c54a14 100644 --- a/lib/private/Sharing/SharingBackend.php +++ b/lib/private/Sharing/SharingBackend.php @@ -1482,4 +1482,27 @@ private function isNullOrEqual(IQueryBuilder $query, string $field, ?string $val ? $query->expr()->isNull($field) : $query->expr()->eq($field, $query->createNamedParameter($value)); } + + #[\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($row['status_id']); + } + } + + return $userStatuses; + } } diff --git a/lib/unstable/Sharing/ISharingBackend.php b/lib/unstable/Sharing/ISharingBackend.php index 410c591a123e8..e47a8fac12513 100644 --- a/lib/unstable/Sharing/ISharingBackend.php +++ b/lib/unstable/Sharing/ISharingBackend.php @@ -232,4 +232,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 5893022f3226e9f18562bda106bcab0c2fa0d4dc Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 13 Oct 2026 10:18:47 +0200 Subject: [PATCH 14/22] 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 be3b0c8e09567..ad65dabf4b32a 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/apps/files/composer/composer/autoload_classmap.php @@ -107,6 +107,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 180e99bedf855..ac723f6e116cc 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/apps/files/composer/composer/autoload_static.php @@ -122,6 +122,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 c967a392a1d8b..2f6a0480a2836 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -44,6 +44,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 OCP\AppFramework\App; @@ -120,6 +121,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 3ebaecf66b33d188c7eaf3dfdcdeaa4cb0909dac Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 13 Oct 2026 10:19:36 +0200 Subject: [PATCH 15/22] 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 3b0344cf28ece15db3ddf41fed50e23d7b0453a7 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 27 Oct 2026 09:40:10 +0100 Subject: [PATCH 16/22] fix(Share20): Fix restoring target after share was created Signed-off-by: provokateurin --- lib/private/Share20/Manager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/private/Share20/Manager.php b/lib/private/Share20/Manager.php index 1d89b1e9e1846..f8f9a921cc4e0 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -612,7 +612,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() === null) { $share->setTarget($target); } } catch (AlreadySharedException $e) { From 78e001aa43e87059e6fd6752aa51c286720e4440 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 27 Oct 2026 09:41:50 +0100 Subject: [PATCH 17/22] fix(FederatedShareProvider): Use existing share time and target if available Signed-off-by: provokateurin --- .../lib/FederatedShareProvider.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index 81cbd45bd9226..acdc0cc3cd8f3 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -8,6 +8,7 @@ namespace OCA\FederatedFileSharing; +use DateTime; use OC\Share20\Exception\InvalidShare; use OC\Share20\Share; use OCP\Constants; @@ -135,7 +136,7 @@ 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); + $shareId = $this->addShareToDB($itemSource, $itemType, $shareWith, $sharedBy, $ownerCloudId->getId(), $permissions, $share->getToken() ?? ('tmp_token_' . time()), $shareType, $expirationDate, $share->getShareTime()); [$token, $remoteId] = $this->notifications->requestReShare( $remoteShare['refresh_token'], $remoteShare['remote_id'], @@ -170,7 +171,7 @@ public function create(IShare $share): IShare { * @throws \Exception */ protected function createFederatedShare(IShare $share): string { - $token = $this->tokenHandler->generateToken(); + $token = $share->getToken() ?? $this->tokenHandler->generateToken(); $shareId = $this->addShareToDB( $share->getNodeId(), $share->getNodeType(), @@ -180,7 +181,8 @@ protected function createFederatedShare(IShare $share): string { $share->getPermissions(), $token, $share->getShareType(), - $share->getExpirationDate() + $share->getExpirationDate(), + $share->getShareTime(), ); $failure = false; @@ -261,7 +263,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)) @@ -274,7 +276,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 From e024b2d148b77ff3c5b48d0abe170b3b14699b7b Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 27 Oct 2026 09:42:20 +0100 Subject: [PATCH 18/22] test(FederatedShareProvider): Don't send remote share notification in unit tests Signed-off-by: provokateurin --- .../lib/FederatedShareProvider.php | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index acdc0cc3cd8f3..99b3baef84434 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -194,18 +194,23 @@ protected function createFederatedShare(IShare $share): string { $sharedByFederatedId = $cloudId->getId(); } $ownerCloudId = $this->cloudIdManager->getCloudId($share->getShareOwner(), $this->addressHandler->generateRemoteURL()); - $send = $this->notifications->sendRemoteShare( - $token, - $share->getSharedWith(), - $share->getNode()->getName(), - $shareId, - $share->getShareOwner(), - $ownerCloudId->getId(), - $share->getSharedBy(), - $sharedByFederatedId, - $share->getShareType(), - $share->getPermissions() - ); + if (defined('PHPUNIT_RUN')) { + // Can't send OCM notifications during unit tests + $send = true; + } else { + $send = $this->notifications->sendRemoteShare( + $token, + $share->getSharedWith(), + $share->getNode()->getName(), + $shareId, + $share->getShareOwner(), + $ownerCloudId->getId(), + $share->getSharedBy(), + $sharedByFederatedId, + $share->getShareType(), + $share->getPermissions() + ); + } if ($send === false) { $failure = true; From ca742e82c8ef72c0b9cc34e2e8ccf1b1986ea291 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 27 Oct 2026 10:18:08 +0100 Subject: [PATCH 19/22] 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 6d573006ea3c5..81171ddc8945b 100644 --- a/lib/private/Sharing/SharingManager.php +++ b/lib/private/Sharing/SharingManager.php @@ -732,11 +732,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); @@ -930,7 +927,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(); } } @@ -961,7 +958,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 84efc96a7198f9d356e0bc0481fcfc9ee458c0f4 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 23 Sep 2026 13:51:15 +0200 Subject: [PATCH 20/22] feat(Sharing): Implement bi-directional syncing with legacy sharing Signed-off-by: provokateurin --- apps/files/lib/AppInfo/Application.php | 8 +- apps/sharing/appinfo/info.xml | 2 +- .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + .../Version1000Date20260903133238.php | 36 + core/AppInfo/Application.php | 4 + lib/composer/composer/autoload_classmap.php | 3 + lib/composer/composer/autoload_static.php | 3 + lib/private/Sharing/LegacyMapper.php | 139 ++ lib/private/Sharing/LegacyMapping.php | 43 + lib/private/Sharing/SharingBackend.php | 2 +- lib/private/Sharing/SharingLegacySync.php | 1186 ++++++++++ lib/private/Sharing/SharingManager.php | 10 + psalm-strict.xml | 1 + tests/lib/Sharing/SharingLegacySyncTest.php | 2081 +++++++++++++++++ 15 files changed, 3516 insertions(+), 4 deletions(-) create mode 100644 apps/sharing/lib/Migration/Version1000Date20260903133238.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 2f6a0480a2836..57eca21d75911 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -109,6 +109,12 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(RestrictInteractionEvent::class, RestrictInteractionListener::class); + $context->registerEventListener(SharesUpdatedEvent::class, SharesUpdatedListener::class); + + $this->registerSharing(); + } + + private function registerSharing(): void { $registry = Server::get(ISharingRegistry::class); $registry->registerSourceType(Server::get(NodeShareSourceType::class)); @@ -141,8 +147,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(SharesUpdatedEvent::class, SharesUpdatedListener::class); } #[\Override] diff --git a/apps/sharing/appinfo/info.xml b/apps/sharing/appinfo/info.xml index b46cd273f56f8..c78760cb54a8a 100644 --- a/apps/sharing/appinfo/info.xml +++ b/apps/sharing/appinfo/info.xml @@ -9,7 +9,7 @@ Sharing This app provides APIs and occ commands to manage shares. This app provides APIs and occ commands to manage shares. - 2.0.0-dev.3 + 2.0.0-dev.4 AGPL-3.0-or-later Kate Döen Sharing diff --git a/apps/sharing/composer/composer/autoload_classmap.php b/apps/sharing/composer/composer/autoload_classmap.php index d2b48b8753319..b687f4694eab0 100644 --- a/apps/sharing/composer/composer/autoload_classmap.php +++ b/apps/sharing/composer/composer/autoload_classmap.php @@ -32,6 +32,7 @@ 'OCA\\Sharing\\Migration\\Version1000Date20260826073021' => $baseDir . '/../lib/Migration/Version1000Date20260826073021.php', 'OCA\\Sharing\\Migration\\Version1000Date20260826115938' => $baseDir . '/../lib/Migration/Version1000Date20260826115938.php', 'OCA\\Sharing\\Migration\\Version1000Date20260826122228' => $baseDir . '/../lib/Migration/Version1000Date20260826122228.php', + 'OCA\\Sharing\\Migration\\Version1000Date20260903133238' => $baseDir . '/../lib/Migration/Version1000Date20260903133238.php', 'OCA\\Sharing\\Migration\\Version2000Date20261013142538' => $baseDir . '/../lib/Migration/Version2000Date20261013142538.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 60852a2cd612b..61f5b89c6a5cc 100644 --- a/apps/sharing/composer/composer/autoload_static.php +++ b/apps/sharing/composer/composer/autoload_static.php @@ -47,6 +47,7 @@ class ComposerStaticInitSharing 'OCA\\Sharing\\Migration\\Version1000Date20260826073021' => __DIR__ . '/..' . '/../lib/Migration/Version1000Date20260826073021.php', 'OCA\\Sharing\\Migration\\Version1000Date20260826115938' => __DIR__ . '/..' . '/../lib/Migration/Version1000Date20260826115938.php', 'OCA\\Sharing\\Migration\\Version1000Date20260826122228' => __DIR__ . '/..' . '/../lib/Migration/Version1000Date20260826122228.php', + 'OCA\\Sharing\\Migration\\Version1000Date20260903133238' => __DIR__ . '/..' . '/../lib/Migration/Version1000Date20260903133238.php', 'OCA\\Sharing\\Migration\\Version2000Date20261013142538' => __DIR__ . '/..' . '/../lib/Migration/Version2000Date20261013142538.php', 'OCA\\Sharing\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php', ); diff --git a/apps/sharing/lib/Migration/Version1000Date20260903133238.php b/apps/sharing/lib/Migration/Version1000Date20260903133238.php new file mode 100644 index 0000000000000..a905538d59040 --- /dev/null +++ b/apps/sharing/lib/Migration/Version1000Date20260903133238.php @@ -0,0 +1,36 @@ +createTable('sharing_share_legacy_mapping'); + $legacyMappingTable->addColumn('id', Types::BIGINT); + $legacyMappingTable->addColumn('legacy_provider', Types::STRING, ['length' => 128]); + $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', 'legacy_id']); + $legacyMappingTable->addIndex(['id']); + + return $schema; + } +} diff --git a/core/AppInfo/Application.php b/core/AppInfo/Application.php index 31b79d4e85213..210f03e68b1ed 100644 --- a/core/AppInfo/Application.php +++ b/core/AppInfo/Application.php @@ -124,6 +124,10 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UserDeletedEvent::class, UserDeletedDirectEditingTokenCleanupListener::class); $context->registerEventListener(UserChangedEvent::class, UserDisabledDirectEditingTokenCleanupListener::class); + $this->registerSharing(); + } + + private function registerSharing(): void { $registry = Server::get(ISharingRegistry::class); $registry->registerRecipientType(Server::get(EmailShareRecipientType::class)); diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 5484e49152a95..8bc58fc7f1de7 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -2394,7 +2394,10 @@ 'OC\\Share20\\UserRemovedListener' => $baseDir . '/lib/private/Share20/UserRemovedListener.php', 'OC\\Share\\Constants' => $baseDir . '/lib/private/Share/Constants.php', 'OC\\Sharing\\ClassMapper' => $baseDir . '/lib/private/Sharing/ClassMapper.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 57adfc3613820..5126dca67553a 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -2435,7 +2435,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Share20\\UserRemovedListener' => __DIR__ . '/../../..' . '/lib/private/Share20/UserRemovedListener.php', 'OC\\Share\\Constants' => __DIR__ . '/../../..' . '/lib/private/Share/Constants.php', 'OC\\Sharing\\ClassMapper' => __DIR__ . '/../../..' . '/lib/private/Sharing/ClassMapper.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..205a95703acef --- /dev/null +++ b/lib/private/Sharing/LegacyMapper.php @@ -0,0 +1,139 @@ + $recipientSecrets + */ + public function createLegacyMapping(string $id, string $legacyProvider, 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' => $qb->createNamedParameter($legacyProvider), + '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, + $legacyProvider, + $legacyId, + $lastUpdated, + $recipientSecrets, + $recipientValue, + ); + } + + public function getLegacyMappingByLegacyProviderAndId(string $legacyProvider, 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', $qb->createNamedParameter($legacyProvider))) + ->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'], + $legacyProvider, + $legacyId, + SharingBackend::parseTimestamp((string)$row['last_updated']), + $this->decodeRecipientSecrets($row['recipient_secrets']), + $row['recipient_value'], + ); + } + + /** + * @return list + */ + public function getLegacyMappings(string $id): array { + $legacyMappings = []; + + $qb = $this->dbConnection->getQueryBuilder(); + $result = $qb + ->select('id', 'legacy_provider', '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) { + $legacyMappings[] = new LegacyMapping( + (string)$row['id'], + $row['legacy_provider'], + $row['legacy_id'], + SharingBackend::parseTimestamp((string)$row['last_updated']), + $this->decodeRecipientSecrets($row['recipient_secrets']), + $row['recipient_value'], + ); + } + + return $legacyMappings; + } + + 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(); + } + + 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', $qb->createNamedParameter($legacyMapping->legacyProvider))) + ->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..d456bd303d258 --- /dev/null +++ b/lib/private/Sharing/LegacyMapping.php @@ -0,0 +1,43 @@ + $recipientSecrets + */ + public array $recipientSecrets, + public string $recipientValue, + ) { + } + + /** + * @throws ShareNotFound + */ + public function getLegacyShare(IManager $legacySharingManager): IShare { + // Not cached on purpose, so we always get the current state of the share. + return $legacySharingManager->getShareById( + $this->legacyProvider . ':' . $this->legacyId, + onlyValid: false, + ); + } +} diff --git a/lib/private/Sharing/SharingBackend.php b/lib/private/Sharing/SharingBackend.php index 310c597c54a14..847feddd93839 100644 --- a/lib/private/Sharing/SharingBackend.php +++ b/lib/private/Sharing/SharingBackend.php @@ -1456,7 +1456,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..de5fdeb223873 --- /dev/null +++ b/lib/private/Sharing/SharingLegacySync.php @@ -0,0 +1,1186 @@ + + */ +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 IEventDispatcher $eventDispatcher, + 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, + ) { + $this->eventDispatcher->addServiceListener(ShareAcceptedEvent::class, self::class); + $this->eventDispatcher->addServiceListener(ShareCreatedEvent::class, self::class); + $this->eventDispatcher->addServiceListener(ShareDeletedEvent::class, self::class); + $this->eventDispatcher->addServiceListener(ShareDeletedFromSelfEvent::class, self::class); + $this->eventDispatcher->addServiceListener(ShareMovedEvent::class, self::class); + $this->eventDispatcher->addServiceListener(ShareRestoredEvent::class, self::class); + $this->eventDispatcher->addServiceListener(ShareUpdatedEvent::class, self::class); + $this->eventDispatcher->addServiceListener(SharesUpdatedEvent::class, self::class); + $this->eventDispatcher->addServiceListener(SharesDeletedEvent::class, self::class); + } + + 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; + } + + // TODO: Test + public function mapLegacyShares(?IUser $user, ?int $limit = null): void { + // TODO: Make it work with all providers (deck, talk, mail, external etc.) + $provider = 'ocinternal'; + // TODO: Filter by user + $qb = $this->dbConnection->getQueryBuilder(); + $result = $qb + ->select('s.id', 's.stime') + ->from('share', 's') + ->leftJoin('s', 'sharing_share_legacy_mapping', 'l', $qb->expr()->eq('s.id', 'l.legacy_id')) + ->where($qb->expr()->isNull('l.legacy_id')) + ->andWhere($qb->expr()->in('s.share_type', $qb->createNamedParameter([ + IShare::TYPE_USER, + IShare::TYPE_REMOTE, + IShare::TYPE_GROUP, + IShare::TYPE_REMOTE_GROUP, + IShare::TYPE_LINK, + IShare::TYPE_EMAIL, + IShare::TYPE_CIRCLE, + ], IQueryBuilder::PARAM_INT_ARRAY))) + ->executeQuery(); + + /** @var list $rows */ + $rows = $result->fetchAll(); + foreach ($rows as $row) { + $id = $this->snowflakeGenerator->nextId(DateTimeImmutable::createFromMutable((new DateTime())->setTimestamp($row['stime']))); + $legacyMapping = $this->legacyMapper->createLegacyMapping( + $id, + $provider, + $row['id'], + new DateTimeImmutable(), + [$this->getSharingManager()->generateSecret()], + $this->getSharingManager()->generateSecret(), + ); + + $this->syncLegacySharesToShare($id, [$legacyMapping]); + } + } + + #[\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(); + $legacyProvider = $legacyShare->getProviderId(); + $legacyId = (int)$legacyShare->getId(); + $legacyMapping = $this->legacyMapper->getLegacyMappingByLegacyProviderAndId($legacyProvider, $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 { + 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 { + $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, $legacyProvider, $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, + $legacyProvider, + $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($this->legacySharingManager)); + } catch (ShareNotFound) { + } + } + + $this->legacyMapper->deleteLegacyMappings($id); + }); + } + + return; + } + + /** @psalm-suppress RedundantConditionGivenDocblockType */ + if ($event instanceof SharesUpdatedEvent) { + foreach ($event->shareIds as $id) { + $this->wrapSync($id, function () use ($id): void { + $share = $this->getSharingManager()->getShare(new ShareAccessContext(overrideChecks: true), $id); + // Only active shares are synced, everything else is dropped + if ($share->state === ShareState::Active) { + $this->syncShareToLegacyShares($share); + } else { + $legacyMappings = $this->legacyMapper->getLegacyMappings($id); + foreach ($legacyMappings as $legacyMapping) { + try { + $this->legacySharingManager->deleteShare($legacyMapping->getLegacyShare($this->legacySharingManager)); + } catch (ShareNotFound) { + } + } + + $this->legacyMapper->deleteLegacyMappings($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($this->legacySharingManager); + $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($this->legacySharingManager); + + $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.'); + } + + // 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], + new ShareUser( + $shareInitiator['value'], + $shareInitiator['instance'], + ), + $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], + new ShareUser( + $shareInitiator['value'], + $shareInitiator['instance'], + ), + $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], + new ShareUser( + $shareInitiator['value'], + $shareInitiator['instance'], + ), + $recipientPermissions, + ); + } + } + + if (!$this->checkAllSame($legacyShares, fn (IShare $share) => $share->getShareOwner())) { + throw new RuntimeException("All legacy shares sharing a share id don't have the same owner"); + } + + $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'], + ); + + if (!$this->checkAllSame($legacyShares, fn (IShare $share) => $share->getExpirationDate())) { + throw new RuntimeException("All legacy shares sharing a share id don't have the expiration date"); + } + + if (!$this->checkAllSame($legacyShares, fn (IShare $share) => $share->getPassword())) { + throw new RuntimeException("All legacy shares sharing a share id don't have the password"); + } + + if (!$this->checkAllSame($legacyShares, fn (IShare $share) => $share->getLabel())) { + throw new RuntimeException("All legacy shares sharing a share id don't have the label"); + } + + if (!$this->checkAllSame($legacyShares, fn (IShare $share) => $share->getNote())) { + throw new RuntimeException("All legacy shares sharing a share id don't have the note"); + } + + $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) { + $this->backend->updateShareUserStatus($share->id, $legacyShare->getSharedWith(), $this->legacyUserStatusToUserStatus($legacyShare->getStatus() ?? IShare::STATUS_PENDING)); + $this->getSourceNodeTargetManager()->setTarget($legacyShare->getSharedWith(), $share->owner, $legacyShare->getNodeId(), $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 { + $this->backend->updateShareUserStatus($share->id, $row['share_with'], $this->legacyUserStatusToUserStatus($row['accepted'])); + } + + $this->getSourceNodeTargetManager()->setTarget($row['share_with'], $share->owner, $legacyShare->getNodeId(), $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($this->legacySharingManager); + + $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 { + 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) { + $target = $this->getSourceNodeTargetManager()->getTarget($recipient->value, $share->owner, (int)$source->value) ?? $this->getSourceNodeTargetManager()->createDefaultTarget($recipient->value, $share->owner, (int)$source->value); + $legacyShare->setTarget($target); + } + + $this->setCommonLegacyShareFields($share, $source, $recipient, $legacyShare); + + $legacyShare = $this->createOrUpdateLegacyShare($share, $legacyMappings, $legacyShare, $recipient->value, [$recipientSecret]); + + foreach ($share->recipients as $recipient) { + // 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); + } + + $userIds = $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 => $userIds) { + foreach (array_chunk($userIds, 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, (int)$source->value) as $userId => $target) { + $userIdsByTarget[$target] ??= []; + $userIdsByTarget[$target][] = $userId; + } + + foreach ($userIdsByTarget as $target => $userIds) { + foreach (array_chunk($userIds, 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($this->legacySharingManager); + + $legacyShare->setId($oldLegacyShare->getId()); + $legacyShare->setProviderId($oldLegacyShare->getProviderId()); + + $this->legacyMapper->updateLegacyMapping(new LegacyMapping( + $legacyMapping->id, + $legacyMapping->legacyProvider, + $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); + $this->ignoreNewLegacyShare = false; + } catch (Exception $exception) { + $this->ignoreNewLegacyShare = false; + throw $exception; + } + + $this->legacyMapper->createLegacyMapping( + $share->id, + $legacyShare->getProviderId(), + (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|false */ + $mimeTypeId = $result->fetchOne(); + if ($mimeTypeId === false) { + throw new RuntimeException('Share source does not exist: ' . $source->value); + } + + $nodeIsFile = $this->mimeTypeLoader->getMimetypeById($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), + }; + } + + /** + * Check that all items return the same result when used as argument to a function + * + * @template T + * @template U + * @param iterable $items + * @param callable(T):U $fn + */ + private function checkAllSame(iterable $items, callable $fn): bool { + $first = true; + $commonValue = null; + foreach ($items as $item) { + $value = $fn($item); + if ($first) { + $commonValue = $value; + $first = false; + } elseif ($value !== $commonValue) { + return false; + } + } + + return true; + } + + /** + * @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 81171ddc8945b..280666f9732a2 100644 --- a/lib/private/Sharing/SharingManager.php +++ b/lib/private/Sharing/SharingManager.php @@ -76,6 +76,7 @@ public function __construct( private ClockInterface $clock, private IManager $legacySharingManager, private IConfig $config, + private SharingLegacySync $legacySync, ) { $this->randomizer = new Randomizer(); $this->l10n = $l10nFactory->get('sharing'); @@ -863,6 +864,10 @@ public function getShares( ?string $lastShareID, ?int $limit, ): array { + if (($currentUser = $accessContext->currentUser) instanceof IUser) { + $this->legacySync->mapLegacyShares($currentUser); + } + return $this->backend->getShares($accessContext, $filterSourceTypeClass, $filterSourceTypeValue, $filterState, $filterUserStatus, $lastShareID, $limit); } @@ -873,8 +878,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); @@ -887,6 +895,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..de512a693b873 100644 --- a/psalm-strict.xml +++ b/psalm-strict.xml @@ -85,6 +85,7 @@ + diff --git a/tests/lib/Sharing/SharingLegacySyncTest.php b/tests/lib/Sharing/SharingLegacySyncTest.php new file mode 100644 index 0000000000000..3c17c88a12ea2 --- /dev/null +++ b/tests/lib/Sharing/SharingLegacySyncTest.php @@ -0,0 +1,2081 @@ +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'); + + $this->dbConnection->beginTransaction(); + } + + #[\Override] + public function tearDown(): void { + $this->clearShares(); + + $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 { + $legacyShare = $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->legacySharingManager->deleteShare($legacyShare); + + $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->getId(), '/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->getId(), '/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); + } + + public function testRemoteSharedWithGroup(): void { + Server::get(IAppConfig::class)->setValueString('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(IAppConfig::class)->deleteKey('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 6f9a5cad698b12936ed5fc3d7fae763eef6681e6 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 23 Sep 2026 13:56:51 +0200 Subject: [PATCH 21/22] 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 2279e869a27eef25bb107566a66e9f069807a30f Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 13 Oct 2026 13:51:42 +0200 Subject: [PATCH 22/22] 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 1e5b4d42ad3b8..eb4b28266798f 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 280666f9732a2..dabda62970a66 100644 --- a/lib/private/Sharing/SharingManager.php +++ b/lib/private/Sharing/SharingManager.php @@ -52,9 +52,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