From ea255f7636e5eeab4b16aa0183e0bab798c74999 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Mon, 4 May 2026 13:43:21 +0200 Subject: [PATCH 1/5] feat(lib/Sharing): Init Signed-off-by: provokateurin --- build/rector-strict.php | 3 + lib/composer/composer/autoload_classmap.php | 34 + lib/composer/composer/autoload_static.php | 34 + lib/private/Server.php | 3 + lib/private/Sharing/SharingManager.php | 694 +++ lib/private/Sharing/SharingRegistry.php | 341 ++ .../Sharing/Exception/AShareException.php | 20 + .../Exception/ShareInvalidException.php | 19 + .../Exception/ShareNotFoundException.php | 27 + .../ShareOperationForbiddenException.php | 27 + lib/public/Sharing/ISharingBackend.php | 192 + lib/public/Sharing/ISharingManager.php | 207 + lib/public/Sharing/ISharingRegistry.php | 158 + lib/public/Sharing/Icon/ShareIconSVG.php | 43 + lib/public/Sharing/Icon/ShareIconURL.php | 50 + .../Permission/ISharePermissionPreset.php | 35 + .../Permission/ISharePermissionType.php | 53 + .../Sharing/Permission/SharePermission.php | 53 + .../Property/ABooleanSharePropertyType.php | 46 + .../Property/ADateSharePropertyType.php | 75 + .../Property/AEnumSharePropertyType.php | 54 + .../Property/APasswordSharePropertyType.php | 101 + .../Property/AStringSharePropertyType.php | 64 + .../Sharing/Property/ISharePropertyType.php | 95 + .../Property/ISharePropertyTypeFilter.php | 29 + .../ISharePropertyTypeModifyValue.php | 34 + lib/public/Sharing/Property/ShareProperty.php | 57 + .../AShareRecipientTypeSearchCollaborator.php | 138 + .../Sharing/Recipient/IShareRecipientType.php | 68 + .../IShareRecipientTypePublicSecret.php | 30 + .../Recipient/IShareRecipientTypeSearch.php | 29 + .../Sharing/Recipient/ShareRecipient.php | 103 + lib/public/Sharing/Share.php | 260 ++ lib/public/Sharing/ShareAccessContext.php | 36 + lib/public/Sharing/ShareState.php | 31 + lib/public/Sharing/ShareUser.php | 64 + .../Sharing/Source/IShareSourceType.php | 60 + lib/public/Sharing/Source/ShareSource.php | 74 + psalm-strict.xml | 3 + psalm.xml | 1 + .../Sharing/AbstractSharingManagerTests.php | 3910 +++++++++++++++++ .../ABooleanSharePropertyTypeTest.php | 66 + .../Property/ADateSharePropertyTypeTest.php | 90 + .../Property/AEnumSharePropertyTypeTest.php | 78 + .../APasswordSharePropertyTypeTest.php | 108 + .../Property/AStringSharePropertyTypeTest.php | 88 + tests/lib/Sharing/SharingManagerTest.php | 218 + tests/lib/Sharing/SharingRegistryTest.php | 171 + tests/lib/Sharing/TestInteractionReceiver.php | 24 + tests/lib/Sharing/TestInteractionResource.php | 24 + .../Sharing/TestSharePermissionPreset1.php | 27 + .../Sharing/TestSharePermissionPreset2.php | 13 + .../lib/Sharing/TestSharePermissionType1.php | 37 + .../lib/Sharing/TestSharePermissionType2.php | 13 + .../lib/Sharing/TestSharePermissionType3.php | 13 + tests/lib/Sharing/TestSharePropertyType1.php | 61 + tests/lib/Sharing/TestSharePropertyType2.php | 13 + .../Sharing/TestSharePropertyTypeFilter.php | 25 + .../TestSharePropertyTypeModifyValue.php | 36 + .../Sharing/TestSharePropertyTypeRequired.php | 22 + tests/lib/Sharing/TestShareRecipientType1.php | 79 + tests/lib/Sharing/TestShareRecipientType2.php | 13 + .../TestShareRecipientTypeArguments.php | 55 + .../TestShareRecipientTypePublicSecret.php | 82 + tests/lib/Sharing/TestShareSourceType1.php | 51 + tests/lib/Sharing/TestShareSourceType2.php | 13 + 66 files changed, 8775 insertions(+) create mode 100644 lib/private/Sharing/SharingManager.php create mode 100644 lib/private/Sharing/SharingRegistry.php create mode 100644 lib/public/Sharing/Exception/AShareException.php create mode 100644 lib/public/Sharing/Exception/ShareInvalidException.php create mode 100644 lib/public/Sharing/Exception/ShareNotFoundException.php create mode 100644 lib/public/Sharing/Exception/ShareOperationForbiddenException.php create mode 100644 lib/public/Sharing/ISharingBackend.php create mode 100644 lib/public/Sharing/ISharingManager.php create mode 100644 lib/public/Sharing/ISharingRegistry.php create mode 100644 lib/public/Sharing/Icon/ShareIconSVG.php create mode 100644 lib/public/Sharing/Icon/ShareIconURL.php create mode 100644 lib/public/Sharing/Permission/ISharePermissionPreset.php create mode 100644 lib/public/Sharing/Permission/ISharePermissionType.php create mode 100644 lib/public/Sharing/Permission/SharePermission.php create mode 100644 lib/public/Sharing/Property/ABooleanSharePropertyType.php create mode 100644 lib/public/Sharing/Property/ADateSharePropertyType.php create mode 100644 lib/public/Sharing/Property/AEnumSharePropertyType.php create mode 100644 lib/public/Sharing/Property/APasswordSharePropertyType.php create mode 100644 lib/public/Sharing/Property/AStringSharePropertyType.php create mode 100644 lib/public/Sharing/Property/ISharePropertyType.php create mode 100644 lib/public/Sharing/Property/ISharePropertyTypeFilter.php create mode 100644 lib/public/Sharing/Property/ISharePropertyTypeModifyValue.php create mode 100644 lib/public/Sharing/Property/ShareProperty.php create mode 100644 lib/public/Sharing/Recipient/AShareRecipientTypeSearchCollaborator.php create mode 100644 lib/public/Sharing/Recipient/IShareRecipientType.php create mode 100644 lib/public/Sharing/Recipient/IShareRecipientTypePublicSecret.php create mode 100644 lib/public/Sharing/Recipient/IShareRecipientTypeSearch.php create mode 100644 lib/public/Sharing/Recipient/ShareRecipient.php create mode 100644 lib/public/Sharing/Share.php create mode 100644 lib/public/Sharing/ShareAccessContext.php create mode 100644 lib/public/Sharing/ShareState.php create mode 100644 lib/public/Sharing/ShareUser.php create mode 100644 lib/public/Sharing/Source/IShareSourceType.php create mode 100644 lib/public/Sharing/Source/ShareSource.php create mode 100644 tests/lib/Sharing/AbstractSharingManagerTests.php create mode 100644 tests/lib/Sharing/Property/ABooleanSharePropertyTypeTest.php create mode 100644 tests/lib/Sharing/Property/ADateSharePropertyTypeTest.php create mode 100644 tests/lib/Sharing/Property/AEnumSharePropertyTypeTest.php create mode 100644 tests/lib/Sharing/Property/APasswordSharePropertyTypeTest.php create mode 100644 tests/lib/Sharing/Property/AStringSharePropertyTypeTest.php create mode 100644 tests/lib/Sharing/SharingManagerTest.php create mode 100644 tests/lib/Sharing/SharingRegistryTest.php create mode 100644 tests/lib/Sharing/TestInteractionReceiver.php create mode 100644 tests/lib/Sharing/TestInteractionResource.php create mode 100644 tests/lib/Sharing/TestSharePermissionPreset1.php create mode 100644 tests/lib/Sharing/TestSharePermissionPreset2.php create mode 100644 tests/lib/Sharing/TestSharePermissionType1.php create mode 100644 tests/lib/Sharing/TestSharePermissionType2.php create mode 100644 tests/lib/Sharing/TestSharePermissionType3.php create mode 100644 tests/lib/Sharing/TestSharePropertyType1.php create mode 100644 tests/lib/Sharing/TestSharePropertyType2.php create mode 100644 tests/lib/Sharing/TestSharePropertyTypeFilter.php create mode 100644 tests/lib/Sharing/TestSharePropertyTypeModifyValue.php create mode 100644 tests/lib/Sharing/TestSharePropertyTypeRequired.php create mode 100644 tests/lib/Sharing/TestShareRecipientType1.php create mode 100644 tests/lib/Sharing/TestShareRecipientType2.php create mode 100644 tests/lib/Sharing/TestShareRecipientTypeArguments.php create mode 100644 tests/lib/Sharing/TestShareRecipientTypePublicSecret.php create mode 100644 tests/lib/Sharing/TestShareSourceType1.php create mode 100644 tests/lib/Sharing/TestShareSourceType2.php diff --git a/build/rector-strict.php b/build/rector-strict.php index a2534b6cf5356..f4a9108100ff5 100644 --- a/build/rector-strict.php +++ b/build/rector-strict.php @@ -38,6 +38,9 @@ $nextcloudDir . '/apps/files_sharing/tests/Listener/RestrictInteractionListenerTest.php', $nextcloudDir . '/core/Listener/RestrictInteractionListener.php', $nextcloudDir . '/tests/Core/Listener/RestrictInteractionListenerTest.php', + $nextcloudDir . '/lib/public/Sharing', + $nextcloudDir . '/lib/private/Sharing', + $nextcloudDir . '/tests/lib/Sharing', ]) ->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 c406238f396af..a55135b28d737 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -896,6 +896,38 @@ 'OCP\\Share\\ShareReview\\RegisterShareReviewSourceEvent' => $baseDir . '/lib/public/Share/ShareReview/RegisterShareReviewSourceEvent.php', 'OCP\\Share\\ShareReview\\ShareReviewEntry' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewEntry.php', 'OCP\\Share\\ShareReview\\ShareReviewPermission' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewPermission.php', + 'OCP\\Sharing\\Exception\\AShareException' => $baseDir . '/lib/public/Sharing/Exception/AShareException.php', + 'OCP\\Sharing\\Exception\\ShareInvalidException' => $baseDir . '/lib/public/Sharing/Exception/ShareInvalidException.php', + 'OCP\\Sharing\\Exception\\ShareNotFoundException' => $baseDir . '/lib/public/Sharing/Exception/ShareNotFoundException.php', + 'OCP\\Sharing\\Exception\\ShareOperationForbiddenException' => $baseDir . '/lib/public/Sharing/Exception/ShareOperationForbiddenException.php', + 'OCP\\Sharing\\ISharingBackend' => $baseDir . '/lib/public/Sharing/ISharingBackend.php', + 'OCP\\Sharing\\ISharingManager' => $baseDir . '/lib/public/Sharing/ISharingManager.php', + 'OCP\\Sharing\\ISharingRegistry' => $baseDir . '/lib/public/Sharing/ISharingRegistry.php', + 'OCP\\Sharing\\Icon\\ShareIconSVG' => $baseDir . '/lib/public/Sharing/Icon/ShareIconSVG.php', + 'OCP\\Sharing\\Icon\\ShareIconURL' => $baseDir . '/lib/public/Sharing/Icon/ShareIconURL.php', + 'OCP\\Sharing\\Permission\\ISharePermissionPreset' => $baseDir . '/lib/public/Sharing/Permission/ISharePermissionPreset.php', + 'OCP\\Sharing\\Permission\\ISharePermissionType' => $baseDir . '/lib/public/Sharing/Permission/ISharePermissionType.php', + 'OCP\\Sharing\\Permission\\SharePermission' => $baseDir . '/lib/public/Sharing/Permission/SharePermission.php', + 'OCP\\Sharing\\Property\\ABooleanSharePropertyType' => $baseDir . '/lib/public/Sharing/Property/ABooleanSharePropertyType.php', + 'OCP\\Sharing\\Property\\ADateSharePropertyType' => $baseDir . '/lib/public/Sharing/Property/ADateSharePropertyType.php', + 'OCP\\Sharing\\Property\\AEnumSharePropertyType' => $baseDir . '/lib/public/Sharing/Property/AEnumSharePropertyType.php', + 'OCP\\Sharing\\Property\\APasswordSharePropertyType' => $baseDir . '/lib/public/Sharing/Property/APasswordSharePropertyType.php', + 'OCP\\Sharing\\Property\\AStringSharePropertyType' => $baseDir . '/lib/public/Sharing/Property/AStringSharePropertyType.php', + 'OCP\\Sharing\\Property\\ISharePropertyType' => $baseDir . '/lib/public/Sharing/Property/ISharePropertyType.php', + 'OCP\\Sharing\\Property\\ISharePropertyTypeFilter' => $baseDir . '/lib/public/Sharing/Property/ISharePropertyTypeFilter.php', + 'OCP\\Sharing\\Property\\ISharePropertyTypeModifyValue' => $baseDir . '/lib/public/Sharing/Property/ISharePropertyTypeModifyValue.php', + 'OCP\\Sharing\\Property\\ShareProperty' => $baseDir . '/lib/public/Sharing/Property/ShareProperty.php', + 'OCP\\Sharing\\Recipient\\AShareRecipientTypeSearchCollaborator' => $baseDir . '/lib/public/Sharing/Recipient/AShareRecipientTypeSearchCollaborator.php', + 'OCP\\Sharing\\Recipient\\IShareRecipientType' => $baseDir . '/lib/public/Sharing/Recipient/IShareRecipientType.php', + 'OCP\\Sharing\\Recipient\\IShareRecipientTypePublicSecret' => $baseDir . '/lib/public/Sharing/Recipient/IShareRecipientTypePublicSecret.php', + 'OCP\\Sharing\\Recipient\\IShareRecipientTypeSearch' => $baseDir . '/lib/public/Sharing/Recipient/IShareRecipientTypeSearch.php', + 'OCP\\Sharing\\Recipient\\ShareRecipient' => $baseDir . '/lib/public/Sharing/Recipient/ShareRecipient.php', + 'OCP\\Sharing\\Share' => $baseDir . '/lib/public/Sharing/Share.php', + 'OCP\\Sharing\\ShareAccessContext' => $baseDir . '/lib/public/Sharing/ShareAccessContext.php', + 'OCP\\Sharing\\ShareState' => $baseDir . '/lib/public/Sharing/ShareState.php', + 'OCP\\Sharing\\ShareUser' => $baseDir . '/lib/public/Sharing/ShareUser.php', + 'OCP\\Sharing\\Source\\IShareSourceType' => $baseDir . '/lib/public/Sharing/Source/IShareSourceType.php', + 'OCP\\Sharing\\Source\\ShareSource' => $baseDir . '/lib/public/Sharing/Source/ShareSource.php', 'OCP\\Snowflake\\ISnowflakeDecoder' => $baseDir . '/lib/public/Snowflake/ISnowflakeDecoder.php', 'OCP\\Snowflake\\ISnowflakeGenerator' => $baseDir . '/lib/public/Snowflake/ISnowflakeGenerator.php', 'OCP\\Snowflake\\Snowflake' => $baseDir . '/lib/public/Snowflake/Snowflake.php', @@ -2247,6 +2279,8 @@ 'OC\\Share20\\UserDeletedListener' => $baseDir . '/lib/private/Share20/UserDeletedListener.php', 'OC\\Share20\\UserRemovedListener' => $baseDir . '/lib/private/Share20/UserRemovedListener.php', 'OC\\Share\\Constants' => $baseDir . '/lib/private/Share/Constants.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', 'OC\\Snowflake\\FileSequence' => $baseDir . '/lib/private/Snowflake/FileSequence.php', 'OC\\Snowflake\\ISequence' => $baseDir . '/lib/private/Snowflake/ISequence.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index d04f6e5b986d1..5da8d4b59e855 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -937,6 +937,38 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Share\\ShareReview\\RegisterShareReviewSourceEvent' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/RegisterShareReviewSourceEvent.php', 'OCP\\Share\\ShareReview\\ShareReviewEntry' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewEntry.php', 'OCP\\Share\\ShareReview\\ShareReviewPermission' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewPermission.php', + 'OCP\\Sharing\\Exception\\AShareException' => __DIR__ . '/../../..' . '/lib/public/Sharing/Exception/AShareException.php', + 'OCP\\Sharing\\Exception\\ShareInvalidException' => __DIR__ . '/../../..' . '/lib/public/Sharing/Exception/ShareInvalidException.php', + 'OCP\\Sharing\\Exception\\ShareNotFoundException' => __DIR__ . '/../../..' . '/lib/public/Sharing/Exception/ShareNotFoundException.php', + 'OCP\\Sharing\\Exception\\ShareOperationForbiddenException' => __DIR__ . '/../../..' . '/lib/public/Sharing/Exception/ShareOperationForbiddenException.php', + 'OCP\\Sharing\\ISharingBackend' => __DIR__ . '/../../..' . '/lib/public/Sharing/ISharingBackend.php', + 'OCP\\Sharing\\ISharingManager' => __DIR__ . '/../../..' . '/lib/public/Sharing/ISharingManager.php', + 'OCP\\Sharing\\ISharingRegistry' => __DIR__ . '/../../..' . '/lib/public/Sharing/ISharingRegistry.php', + 'OCP\\Sharing\\Icon\\ShareIconSVG' => __DIR__ . '/../../..' . '/lib/public/Sharing/Icon/ShareIconSVG.php', + 'OCP\\Sharing\\Icon\\ShareIconURL' => __DIR__ . '/../../..' . '/lib/public/Sharing/Icon/ShareIconURL.php', + 'OCP\\Sharing\\Permission\\ISharePermissionPreset' => __DIR__ . '/../../..' . '/lib/public/Sharing/Permission/ISharePermissionPreset.php', + 'OCP\\Sharing\\Permission\\ISharePermissionType' => __DIR__ . '/../../..' . '/lib/public/Sharing/Permission/ISharePermissionType.php', + 'OCP\\Sharing\\Permission\\SharePermission' => __DIR__ . '/../../..' . '/lib/public/Sharing/Permission/SharePermission.php', + 'OCP\\Sharing\\Property\\ABooleanSharePropertyType' => __DIR__ . '/../../..' . '/lib/public/Sharing/Property/ABooleanSharePropertyType.php', + 'OCP\\Sharing\\Property\\ADateSharePropertyType' => __DIR__ . '/../../..' . '/lib/public/Sharing/Property/ADateSharePropertyType.php', + 'OCP\\Sharing\\Property\\AEnumSharePropertyType' => __DIR__ . '/../../..' . '/lib/public/Sharing/Property/AEnumSharePropertyType.php', + 'OCP\\Sharing\\Property\\APasswordSharePropertyType' => __DIR__ . '/../../..' . '/lib/public/Sharing/Property/APasswordSharePropertyType.php', + 'OCP\\Sharing\\Property\\AStringSharePropertyType' => __DIR__ . '/../../..' . '/lib/public/Sharing/Property/AStringSharePropertyType.php', + 'OCP\\Sharing\\Property\\ISharePropertyType' => __DIR__ . '/../../..' . '/lib/public/Sharing/Property/ISharePropertyType.php', + 'OCP\\Sharing\\Property\\ISharePropertyTypeFilter' => __DIR__ . '/../../..' . '/lib/public/Sharing/Property/ISharePropertyTypeFilter.php', + 'OCP\\Sharing\\Property\\ISharePropertyTypeModifyValue' => __DIR__ . '/../../..' . '/lib/public/Sharing/Property/ISharePropertyTypeModifyValue.php', + 'OCP\\Sharing\\Property\\ShareProperty' => __DIR__ . '/../../..' . '/lib/public/Sharing/Property/ShareProperty.php', + 'OCP\\Sharing\\Recipient\\AShareRecipientTypeSearchCollaborator' => __DIR__ . '/../../..' . '/lib/public/Sharing/Recipient/AShareRecipientTypeSearchCollaborator.php', + 'OCP\\Sharing\\Recipient\\IShareRecipientType' => __DIR__ . '/../../..' . '/lib/public/Sharing/Recipient/IShareRecipientType.php', + 'OCP\\Sharing\\Recipient\\IShareRecipientTypePublicSecret' => __DIR__ . '/../../..' . '/lib/public/Sharing/Recipient/IShareRecipientTypePublicSecret.php', + 'OCP\\Sharing\\Recipient\\IShareRecipientTypeSearch' => __DIR__ . '/../../..' . '/lib/public/Sharing/Recipient/IShareRecipientTypeSearch.php', + 'OCP\\Sharing\\Recipient\\ShareRecipient' => __DIR__ . '/../../..' . '/lib/public/Sharing/Recipient/ShareRecipient.php', + 'OCP\\Sharing\\Share' => __DIR__ . '/../../..' . '/lib/public/Sharing/Share.php', + 'OCP\\Sharing\\ShareAccessContext' => __DIR__ . '/../../..' . '/lib/public/Sharing/ShareAccessContext.php', + 'OCP\\Sharing\\ShareState' => __DIR__ . '/../../..' . '/lib/public/Sharing/ShareState.php', + 'OCP\\Sharing\\ShareUser' => __DIR__ . '/../../..' . '/lib/public/Sharing/ShareUser.php', + 'OCP\\Sharing\\Source\\IShareSourceType' => __DIR__ . '/../../..' . '/lib/public/Sharing/Source/IShareSourceType.php', + 'OCP\\Sharing\\Source\\ShareSource' => __DIR__ . '/../../..' . '/lib/public/Sharing/Source/ShareSource.php', 'OCP\\Snowflake\\ISnowflakeDecoder' => __DIR__ . '/../../..' . '/lib/public/Snowflake/ISnowflakeDecoder.php', 'OCP\\Snowflake\\ISnowflakeGenerator' => __DIR__ . '/../../..' . '/lib/public/Snowflake/ISnowflakeGenerator.php', 'OCP\\Snowflake\\Snowflake' => __DIR__ . '/../../..' . '/lib/public/Snowflake/Snowflake.php', @@ -2288,6 +2320,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Share20\\UserDeletedListener' => __DIR__ . '/../../..' . '/lib/private/Share20/UserDeletedListener.php', 'OC\\Share20\\UserRemovedListener' => __DIR__ . '/../../..' . '/lib/private/Share20/UserRemovedListener.php', 'OC\\Share\\Constants' => __DIR__ . '/../../..' . '/lib/private/Share/Constants.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', 'OC\\Snowflake\\FileSequence' => __DIR__ . '/../../..' . '/lib/private/Snowflake/FileSequence.php', 'OC\\Snowflake\\ISequence' => __DIR__ . '/../../..' . '/lib/private/Snowflake/ISequence.php', diff --git a/lib/private/Server.php b/lib/private/Server.php index 3d79fd87ce632..deeee30249dfe 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -1151,6 +1151,9 @@ function () use ($c) { $this->registerAlias(IJobRuns::class, JobRuns::class); $this->registerAlias(IServerInfo::class, ServerInfo::class); + $this->registerAlias(\OCP\Sharing\ISharingRegistry::class, \OC\Sharing\SharingRegistry::class); + $this->registerAlias(\OCP\Sharing\ISharingManager::class, \OC\Sharing\SharingManager::class); + $this->connectDispatcher(); } diff --git a/lib/private/Sharing/SharingManager.php b/lib/private/Sharing/SharingManager.php new file mode 100644 index 0000000000000..bc2251ee7c625 --- /dev/null +++ b/lib/private/Sharing/SharingManager.php @@ -0,0 +1,694 @@ + + */ +final readonly class SharingManager implements ISharingManager, IEventListener { + private Randomizer $randomizer; + + private ICache $backendCache; + + private IL10N $l10n; + + public function __construct( + ICacheFactory $cacheFactory, + IEventDispatcher $eventDispatcher, + private IUserManager $userManager, + private IFactory $l10nFactory, + private IDBConnection $dbConnection, + private ISharingRegistry $registry, + ) { + $this->randomizer = new Randomizer(); + + if ($cacheFactory->isAvailable()) { + $this->backendCache = $cacheFactory->createDistributed('sharing'); + } elseif ($cacheFactory->isLocalCacheAvailable()) { + $this->backendCache = $cacheFactory->createLocal('sharing'); + } else { + $this->backendCache = $cacheFactory->createInMemory(); + } + + $eventDispatcher->addServiceListener(BeforeUserDeletedEvent::class, self::class); + + $this->l10n = $this->l10nFactory->get('sharing'); + } + + #[\Override] + public function searchRecipients(ShareAccessContext $accessContext, ?array $recipientTypeClasses, string $query, int $limit, int $offset): array { + $recipientTypes = $this->registry->getRecipientTypes(); + + if ($recipientTypeClasses !== null) { + $filteredRecipientTypes = []; + foreach (array_unique($recipientTypeClasses) as $recipientTypeClass) { + if (($recipientType = $recipientTypes[$recipientTypeClass] ?? null) === null) { + throw new RuntimeException('The recipient type is not registered: ' . $recipientTypeClass); + } + + if (!$recipientType instanceof IShareRecipientTypeSearch) { + throw new RuntimeException('The recipient type is not searchable: ' . $recipientTypeClass); + } + + $filteredRecipientTypes[] = $recipientType; + } + + $recipientTypes = $filteredRecipientTypes; + } else { + $recipientTypes = array_values(array_filter( + $recipientTypes, + static fn (IShareRecipientType $recipientType): bool => $recipientType instanceof IShareRecipientTypeSearch, + )); + } + + return array_merge(...array_map( + static fn (IShareRecipientTypeSearch $recipientType): array => $recipientType->searchRecipients($accessContext, $query, $limit, $offset), + $recipientTypes, + )); + } + + #[\Override] + public function generateSecret(): string { + /** @var non-empty-string $secret */ + $secret = $this->randomizer->getBytesFromString(ISecureRandom::CHAR_ALPHANUMERIC, 32); + return $secret; + } + + #[\Override] + public function generateTimestamp(): int { + $time = (int)(microtime(true) * 1000.0); + if ($time < 0) { + throw new RuntimeException('Have you invented time travel?'); + } + + return $time; + } + + #[\Override] + public function createShare(ShareAccessContext $accessContext): string { + if (!($currentUser = $accessContext->currentUser) instanceof IUser) { + throw new RuntimeException('No user present to create a share'); + } + + $this->assertInTransaction(); + + $backend = $this->getBackend(null); + $id = $backend->createShare($currentUser); + $this->backendCache->set($id, $backend::class); + + return $id; + } + + #[\Override] + public function onOwnerDeleted(ShareAccessContext $accessContext, IUser $owner): void { + if (!$accessContext->overrideChecks) { + throw new RuntimeException('Only possible if checks are overridden.'); + } + + $this->assertInTransaction(); + + // No need to update the last updated timestamp, because the share will be deleted anyway. + + foreach ($this->registry->getSharingBackends() as $backend) { + $backend->onOwnerDeleted($owner); + } + } + + #[\Override] + public function updateShareState(ShareAccessContext $accessContext, string $id, ShareState $state): void { + $this->assertInTransaction(); + + $backend = $this->getBackend($id); + $backend->setLastUpdated([$id], $this->generateTimestamp()); + + $owner = $backend->getShareOwner($id); + $this->validateShareOwnerOperation($accessContext, $owner); + + if ($state === ShareState::Active) { + $share = $this->getShare($accessContext, $id, $backend); + $this->assertShareCanBeActive($share); + } + + $backend->updateShareState($id, $state); + } + + #[\Override] + public function addShareSource(ShareAccessContext $accessContext, string $id, ShareSource $source): void { + $this->assertInTransaction(); + + $backend = $this->getBackend($id); + $backend->setLastUpdated([$id], $this->generateTimestamp()); + + $owner = $backend->getShareOwner($id); + $this->validateShareOwnerOperation($accessContext, $owner); + + if (($sourceType = $this->registry->getSourceTypes()[$source->class] ?? null) === null) { + throw new RuntimeException('The source type is not registered: ' . $source->class); + } + + if (!$sourceType->validateSource($source->value)) { + throw new ShareInvalidException('Invalid source: ' . $source->value . ' ' . $source->class, $this->l10n->t('The source does not exist.')); + } + + $share = $this->getShare($accessContext, $id, $backend); + $sources = $share->sources; + $sources[] = $source; + $this->validateInteraction($accessContext, $owner, $sources, $share->getEnabledPermissions(), $share->recipients); + + $backend->addShareSource($id, $source); + } + + #[\Override] + public function removeShareSource(ShareAccessContext $accessContext, string $id, ShareSource $source): void { + $this->assertInTransaction(); + + $backend = $this->getBackend($id); + $backend->setLastUpdated([$id], $this->generateTimestamp()); + + $owner = $backend->getShareOwner($id); + $this->validateShareOwnerOperation($accessContext, $owner); + + $backend->removeShareSource($id, $source); + + $this->makeSharesDraftIfNeeded([$id]); + } + + #[\Override] + public function onSourceDeleted(ShareAccessContext $accessContext, ShareSource $source): void { + if (!$accessContext->overrideChecks) { + throw new RuntimeException('Only possible if checks are overridden.'); + } + + $this->assertInTransaction(); + + $timestamp = $this->generateTimestamp(); + + $ids = []; + foreach ($this->registry->getSharingBackends() as $backend) { + $updatedIds = $backend->onSourceDeleted($source); + if ($updatedIds === []) { + continue; + } + + $backend->setLastUpdated($updatedIds, $timestamp); + $ids[] = $updatedIds; + } + + $this->makeSharesDraftIfNeeded(array_merge(...$ids)); + } + + #[\Override] + public function addShareRecipient(ShareAccessContext $accessContext, string $id, ShareRecipient $recipient): void { + if (!($currentUser = $accessContext->currentUser) instanceof IUser) { + throw new RuntimeException('No current user provided in access context.'); + } + + $this->assertInTransaction(); + + $backend = $this->getBackend($id); + $backend->setLastUpdated([$id], $this->generateTimestamp()); + + $owner = $backend->getShareOwner($id); + + try { + $this->validateShareOwnerOperation($accessContext, $owner); + $share = null; + } catch (ShareOperationForbiddenException) { + $share = $this->getShare($accessContext, $id, $backend); + $this->validatePermission($share, ReshareSharePermissionType::class); + } + + if (($recipientType = $this->registry->getRecipientTypes()[$recipient->class] ?? null) === null) { + throw new RuntimeException('The recipient type is not registered: ' . $recipient->class); + } + + if (!$recipientType->validateRecipient($recipient->value)) { + throw new ShareInvalidException('Invalid recipient: ' . $recipient->value . ' ' . $recipient->class . ' ' . ($recipient->instance ?? 'local'), $this->l10n->t('The recipient does not exist.')); + } + + $share ??= $this->getShare($accessContext, $id, $backend); + $recipients = $share->recipients; + $recipients[] = $recipient; + $this->validateInteraction($accessContext, $owner, $share->sources, $share->getEnabledPermissions(), $recipients); + + $backend->addShareRecipient($id, $currentUser, $recipient); + } + + #[\Override] + public function removeShareRecipient(ShareAccessContext $accessContext, string $id, ShareRecipient $recipient): void { + $this->assertInTransaction(); + + $this->assertInTransaction(); + + $backend = $this->getBackend($id); + $backend->setLastUpdated([$id], $this->generateTimestamp()); + + $owner = $backend->getShareOwner($id); + + try { + $this->validateShareOwnerOperation($accessContext, $owner); + } catch (ShareOperationForbiddenException) { + $share = $this->getShare($accessContext, $id, $backend); + // This does not allow removing own recipients. A user can only reject a share, but not remove it for the recipient. + $this->validateReshareOperation($accessContext, $share, $recipient); + } + + $backend->removeShareRecipient($id, $recipient); + + $this->makeSharesDraftIfNeeded([$id]); + } + + #[\Override] + public function onRecipientDeleted(ShareAccessContext $accessContext, ShareRecipient $recipient): void { + if (!$accessContext->overrideChecks) { + throw new RuntimeException('Only possible if checks are overridden.'); + } + + $this->assertInTransaction(); + + $timestamp = $this->generateTimestamp(); + + $ids = []; + foreach ($this->registry->getSharingBackends() as $backend) { + $updatedIds = $backend->onRecipientDeleted($recipient); + if ($updatedIds === []) { + continue; + } + + $backend->setLastUpdated($updatedIds, $timestamp); + $ids[] = $updatedIds; + } + + $this->makeSharesDraftIfNeeded(array_merge(...$ids)); + } + + #[\Override] + public function onInitiatorDeleted(ShareAccessContext $accessContext, IUser $initiator): void { + if (!$accessContext->overrideChecks) { + throw new RuntimeException('Only possible if checks are overridden.'); + } + + $this->assertInTransaction(); + + $timestamp = $this->generateTimestamp(); + + foreach ($this->registry->getSharingBackends() as $backend) { + $updatedIds = $backend->onInitiatorDeleted($initiator); + if ($updatedIds === []) { + continue; + } + + $backend->setLastUpdated($updatedIds, $timestamp); + } + + // No need to make shares draft, because promoting a reshare to the owner doesn't remove recipients. + } + + #[\Override] + public function updateShareRecipientSecret(ShareAccessContext $accessContext, string $id, ShareRecipient $recipient, string $secret): void { + $this->assertInTransaction(); + + $backend = $this->getBackend($id); + $backend->setLastUpdated([$id], $this->generateTimestamp()); + + $owner = $backend->getShareOwner($id); + + try { + $this->validateShareOwnerOperation($accessContext, $owner); + } catch (ShareOperationForbiddenException) { + $share = $this->getShare($accessContext, $id, $backend); + $this->validateReshareOperation($accessContext, $share, $recipient); + } + + if (($recipientType = $this->registry->getRecipientTypes()[$recipient->class] ?? null) === null) { + throw new RuntimeException('The recipient type is not registered: ' . $recipient->class); + } + + if (!$recipientType instanceof IShareRecipientTypePublicSecret || !$recipientType->isSecretUpdatable($recipient->value)) { + throw new ShareOperationForbiddenException(); + } + + if (!preg_match('/^[a-z0-9-]{1,32}$/i', $secret)) { + throw new ShareInvalidException('Invalid secret: ' . $secret, $this->l10n->t('The value must be alphanumeric, 1 to 32 characters long and may contain dashes.')); + } + + $backend->updateShareRecipientSecret($id, $recipient, $secret); + } + + #[\Override] + public function updateShareProperty(ShareAccessContext $accessContext, string $id, ShareProperty $property): void { + $this->assertInTransaction(); + + $backend = $this->getBackend($id); + $backend->setLastUpdated([$id], $this->generateTimestamp()); + + $owner = $backend->getShareOwner($id); + $this->validateShareOwnerOperation($accessContext, $owner); + + if (($propertyType = $this->registry->getPropertyTypes()[$property->class] ?? null) === null) { + throw new RuntimeException('The property is not registered: ' . $property->class); + } + + if ($property->value !== null && ($message = $propertyType->validateValue($this->l10nFactory, $property->value)) !== true) { + throw new ShareInvalidException('Invalid property value: ' . $property->value . ' ' . $property->class, $message); + } + + $backend->updateShareProperty($id, $property); + + $this->makeSharesDraftIfNeeded([$id]); + } + + #[\Override] + public function updateSharePermission(ShareAccessContext $accessContext, string $id, SharePermission $permission): void { + $this->assertInTransaction(); + + $backend = $this->getBackend($id); + $backend->setLastUpdated([$id], $this->generateTimestamp()); + + $owner = $backend->getShareOwner($id); + $this->validateShareOwnerOperation($accessContext, $owner); + + if (!isset($this->registry->getPermissionTypes()[$permission->class])) { + throw new RuntimeException('The permission type is not registered: ' . $permission->class); + } + + $share = $this->getShare($accessContext, $id, $backend); + + $permissions = $share->permissions; + $permissions[$permission->class] = $permission; + + $this->validateInteraction($accessContext, $owner, $share->sources, array_filter($permissions, static fn (SharePermission $permission): bool => $permission->enabled), $share->recipients); + + $backend->updateSharePermission($id, $permission); + + $this->makeSharesDraftIfNeeded([$id]); + } + + #[\Override] + public function selectSharePermissionPreset(ShareAccessContext $accessContext, string $id, string $permissionPresetClass): void { + $this->assertInTransaction(); + + $backend = $this->getBackend($id); + $backend->setLastUpdated([$id], $this->generateTimestamp()); + + $owner = $backend->getShareOwner($id); + $this->validateShareOwnerOperation($accessContext, $owner); + + if (($this->registry->getPermissionPresetCompatiblePermissionTypeClasses()[$permissionPresetClass] ?? null) === null) { + throw new RuntimeException('The permission preset is not registered: ' . $permissionPresetClass); + } + + $backend->selectSharePermissionPreset($id, $permissionPresetClass); + } + + #[\Override] + public function deleteShare(ShareAccessContext $accessContext, string $id): void { + $this->assertInTransaction(); + + $backend = $this->getBackend($id); + $owner = $backend->getShareOwner($id); + + // No need to update the last updated timestamp, because the share will be deleted anyway. + + $this->validateShareOwnerOperation($accessContext, $owner); + + $backend->deleteShare($id); + } + + #[\Override] + public function getShare(ShareAccessContext $accessContext, string $id, ?ISharingBackend $backend = null): Share { + $this->assertInTransaction(); + + return ($backend ?? $this->getBackend($id))->getShare($accessContext, $id); + } + + #[\Override] + public function getShares(ShareAccessContext $accessContext, ?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?string $lastShareID, ?int $limit): array { + $this->assertInTransaction(); + + $shares = []; + + // TODO: Deal with more results than limit? + foreach ($this->registry->getSharingBackends() as $backend) { + $backendShares = $backend->getShares($accessContext, $filterSourceTypeClass, $filterSourceTypeValue, $lastShareID, $limit); + $shares[] = $backendShares; + + foreach ($backendShares as $share) { + if ($this->backendCache->get($share->id) === null) { + $this->backendCache->set($share->id, $backend::class); + } + } + } + + return array_merge(...$shares); + } + + #[\Override] + public function handle(Event $event): void { + try { + $this->dbConnection->beginTransaction(); + $this->onOwnerDeleted(new ShareAccessContext(overrideChecks: true), $event->getUser()); + $this->onInitiatorDeleted(new ShareAccessContext(overrideChecks: true), $event->getUser()); + $this->dbConnection->commit(); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } + + private function assertInTransaction(): void { + if (!$this->dbConnection->inTransaction()) { + throw new RuntimeException('The SharingManager can only be used inside a transaction.'); + } + } + + private function getBackend(?string $id): ISharingBackend { + $availableBackends = $this->registry->getSharingBackends(); + if ($availableBackends === []) { + throw new RuntimeException('No sharing backends registered'); + } + + $selectedBackend = null; + if ($id === null) { + // For new shares we only use the backend from the sharing app. + $selectedBackend = $availableBackends[SharingBackend::class] ?? null; + } else { + /** @var ?class-string $cachedBackendClass */ + $cachedBackendClass = $this->backendCache->get($id); + if ($cachedBackendClass !== null) { + $selectedBackend = $availableBackends[$cachedBackendClass] ?? null; + } else { + foreach ($availableBackends as $backend) { + if ($backend->hasShare($id)) { + $selectedBackend = $backend; + $this->backendCache->set($id, $backend::class); + break; + } + } + } + } + + if ($selectedBackend === null) { + throw new RuntimeException('No sharing backend selected'); + } + + return $selectedBackend; + } + + /** + * @throws ShareOperationForbiddenException + */ + private function validateShareOwnerOperation(ShareAccessContext $accessContext, ShareUser $owner): void { + if ($accessContext->overrideChecks) { + return; + } + + if ($owner->instance !== null || !$accessContext->currentUser instanceof IUser || $owner->userId !== $accessContext->currentUser->getUID()) { + throw new ShareOperationForbiddenException(); + } + } + + /** + * @param class-string $permissionTypeClass + * @throws ShareOperationForbiddenException + */ + private function validatePermission(Share $share, string $permissionTypeClass): void { + if ((($permission = $share->permissions[$permissionTypeClass] ?? null) !== null) && $permission->enabled) { + return; + } + + throw new ShareOperationForbiddenException(); + } + + /** + * @throws ShareOperationForbiddenException + */ + private function validateReshareOperation(ShareAccessContext $accessContext, Share $share, ShareRecipient $recipient): void { + $this->validatePermission($share, ReshareSharePermissionType::class); + + foreach ($share->recipients as $shareRecipient) { + if ( + $recipient->class === $shareRecipient->class + && $recipient->value === $shareRecipient->value + && $recipient->instance === $shareRecipient->instance + && $shareRecipient->initiator !== null + && $shareRecipient->initiator->isCurrentUser($accessContext)) { + return; + } + } + + // We're only allowed to remove or update recipients, if we're the initiator. + throw new ShareOperationForbiddenException(); + } + + /** + * @param list $sources + * @param array, SharePermission> $enabledPermissions + * @param list $recipients + * @throws ShareInvalidException + */ + private function validateInteraction(ShareAccessContext $accessContext, ShareUser $owner, array $sources, array $enabledPermissions, array $recipients): void { + $action = new ShareAction(null, array_values(array_map(static fn (SharePermission $permission): string => $permission->class, $enabledPermissions))); + + $usersToCheck = []; + if ($owner->instance === null && ($ownerUser = $this->userManager->get($owner->userId)) !== null) { + $usersToCheck[] = $ownerUser; + } + + if ($accessContext->currentUser instanceof IUser && !$owner->isCurrentUser($accessContext)) { + $usersToCheck[] = $accessContext->currentUser; + } + + $recipientTypes = $this->registry->getRecipientTypes(); + $sourceTypes = $this->registry->getSourceTypes(); + + $receivers = []; + foreach ($recipients as $recipient) { + if (($recipientType = $recipientTypes[$recipient->class] ?? null) === null) { + throw new RuntimeException('The recipient type is not registered: ' . $recipient->class); + } + + if (!$recipientType->validateRecipient($recipient->value)) { + continue; + } + + $receivers[] = $recipientType->getRecipientInteractionReceiver($recipient->value); + } + + foreach ($usersToCheck as $userToCheck) { + $resources = []; + foreach ($sources as $source) { + if (($sourceType = $sourceTypes[$source->class] ?? null) === null) { + throw new RuntimeException('The source type is not registered: ' . $source->class); + } + + if (!$sourceType->validateSource($source->value)) { + continue; + } + + $resources[] = $sourceType->getSourceInteractionResource($userToCheck->getUID(), $source->value); + } + + $event = new RestrictInteractionEvent($userToCheck->getUID(), $userToCheck, $resources, $action, $receivers); + $isRestricted = $event->isInteractionRestricted(); + if ($isRestricted !== false) { + throw new ShareInvalidException('Share interaction restricted.', $isRestricted); + } + } + } + + /** + * @throws ShareInvalidException + */ + private function assertShareCanBeActive(Share $share): void { + if ($share->sources === []) { + throw new ShareInvalidException('No source set.', $this->l10n->t('You need to add at least one source to make the share available.')); + } + + if ($share->recipients === []) { + throw new ShareInvalidException('No recipient set.', $this->l10n->t('You need to add at least one recipient to make the share available.')); + } + + if ($share->getEnabledPermissions() === []) { + throw new ShareInvalidException('No permission given.', $this->l10n->t('You need to allow at least one permission to make the share available.')); + } + + $propertyTypes = $this->registry->getPropertyTypes(); + foreach ($share->properties as $propertyTypeClass => $property) { + $propertyType = $propertyTypes[$propertyTypeClass]; + if ($property->value === null && $propertyType->isRequired()) { + throw new ShareInvalidException('Missing value for required property: ' . $propertyTypeClass, $this->l10n->t('You need to set a value for the %s', [$propertyType->getDisplayName($this->l10nFactory)])); + } + } + } + + /** + * @param list $ids + */ + private function makeSharesDraftIfNeeded(array $ids): void { + foreach ($ids as $id) { + $backend = $this->getBackend($id); + $share = $backend->getShare(new ShareAccessContext(overrideChecks: true), $id); + if ($share->state !== ShareState::Active) { + continue; + } + + try { + $this->assertShareCanBeActive($share); + } catch (ShareInvalidException) { + $backend->updateShareState($id, ShareState::Draft); + } + } + } +} diff --git a/lib/private/Sharing/SharingRegistry.php b/lib/private/Sharing/SharingRegistry.php new file mode 100644 index 0000000000000..3ee949fb1ddfa --- /dev/null +++ b/lib/private/Sharing/SharingRegistry.php @@ -0,0 +1,341 @@ +, ISharingBackend> + */ + private array $backends = []; + + /** @var array, IShareSourceType> */ + private array $sourceTypes = []; + + /** @var array, IShareRecipientType> */ + private array $recipientTypes = []; + + /** @var array, ISharePropertyType> */ + private array $propertyTypes = []; + + /** @var array, array, true>> */ + private array $propertyTypeCompatibleSourceTypes = []; + + /** @var array, array, true>> */ + private array $propertyTypeCompatibleRecipientTypes = []; + + /** @var array, ISharePermissionType> */ + private array $permissionTypes = []; + + /** @var array, ?class-string> */ + private array $permissionTypeSourceType = []; + + /** @var array, list>> */ + private array $sourceTypePermissionTypes = []; + + /** @var list> */ + private array $genericPermissionTypes = []; + + /** @var array, ISharePermissionPreset> */ + private array $permissionPresets = []; + + /** @var array, array, true>> */ + private array $permissionTypeCompatiblePermissionPresets = []; + + /** @var array, array, true>> */ + private array $permissionPresetCompatiblePermissionTypes = []; + + #[\Override] + public function clear(): void { + $this->backends = []; + $this->sourceTypes = []; + $this->recipientTypes = []; + $this->propertyTypes = []; + $this->propertyTypeCompatibleSourceTypes = []; + $this->propertyTypeCompatibleRecipientTypes = []; + $this->permissionTypes = []; + $this->permissionTypeSourceType = []; + $this->sourceTypePermissionTypes = []; + $this->genericPermissionTypes = []; + $this->permissionPresets = []; + $this->permissionTypeCompatiblePermissionPresets = []; + $this->permissionPresetCompatiblePermissionTypes = []; + } + + #[\Override] + public function registerSharingBackend(ISharingBackend $backend): void { + $class = $backend::class; + + if (isset($this->backends[$class])) { + throw new RuntimeException('Sharing backend ' . $class . ' is already registered'); + } + + $this->backends[$class] = $backend; + } + + /** + * @return array, ISharingBackend> + */ + #[\Override] + public function getSharingBackends(): array { + return $this->backends; + } + + #[\Override] + public function registerSourceType(IShareSourceType $sourceType): void { + $class = $sourceType::class; + + if (isset($this->sourceTypes[$class])) { + throw new RuntimeException('Share source type ' . $class . ' is already registered'); + } + + $this->sourceTypes[$class] = $sourceType; + } + + /** + * @return array, IShareSourceType> + */ + #[\Override] + public function getSourceTypes(): array { + return $this->sourceTypes; + } + + /** + * @return array, list>> + */ + #[\Override] + public function getPropertyTypeCompatibleSourceTypeClasses(): array { + return array_map(function (array $sourceTypeClasses): array { + $sourceTypeClasses = array_keys($sourceTypeClasses); + foreach ($sourceTypeClasses as $sourceTypeClass) { + if (!isset($this->sourceTypes[$sourceTypeClass])) { + // Because we can't control the order in which apps are booted, we need to check now if it has been registered. + throw new RuntimeException('Share source type ' . $sourceTypeClass . ' is not registered'); + } + } + + return $sourceTypeClasses; + }, $this->propertyTypeCompatibleSourceTypes); + } + + #[\Override] + public function registerRecipientType(IShareRecipientType $recipientType): void { + $class = $recipientType::class; + + if (isset($this->recipientTypes[$class])) { + throw new RuntimeException('Share recipient type ' . $class . ' is already registered'); + } + + $this->recipientTypes[$class] = $recipientType; + } + + /** + * @return array, IShareRecipientType> + */ + #[\Override] + public function getRecipientTypes(): array { + return $this->recipientTypes; + } + + /** + * @return array, list>> + */ + #[\Override] + public function getPropertyTypeCompatibleRecipientTypes(): array { + return array_map(function (array $recipientTypeClasses): array { + $recipientTypeClasses = array_keys($recipientTypeClasses); + foreach ($recipientTypeClasses as $recipientTypeClass) { + if (!isset($this->recipientTypes[$recipientTypeClass])) { + // Because we can't control the order in which apps are booted, we need to check now if it has been registered. + throw new RuntimeException('Share recipient type ' . $recipientTypeClass . ' is not registered'); + } + } + + return $recipientTypeClasses; + }, $this->propertyTypeCompatibleRecipientTypes); + } + + #[\Override] + public function registerPropertyType(ISharePropertyType $propertyType): void { + $class = $propertyType::class; + + if ($propertyType->isRequired() && $propertyType->getDefaultValue() === null) { + throw new RuntimeException('Share property type ' . $class . ' is required, but has no default value.'); + } + + if (isset($this->propertyTypes[$class])) { + throw new RuntimeException('Share property type ' . $class . ' is already registered'); + } + + $this->propertyTypes[$class] = $propertyType; + } + + #[\Override] + public function markPropertyTypeCompatibleWithSourceType(string $propertyTypeClass, string $sourceTypeClass): void { + // Because we can't control the order in which apps are booted, we can't ensure that the source type is already registered. + $this->propertyTypeCompatibleSourceTypes[$propertyTypeClass] ??= []; + $this->propertyTypeCompatibleSourceTypes[$propertyTypeClass][$sourceTypeClass] = true; + } + + #[\Override] + public function markPropertyTypeCompatibleWithRecipientType(string $propertyTypeClass, string $recipientTypeClass): void { + // Because we can't control the order in which apps are booted, we can't ensure that the source type is already registered. + $this->propertyTypeCompatibleRecipientTypes[$propertyTypeClass] ??= []; + $this->propertyTypeCompatibleRecipientTypes[$propertyTypeClass][$recipientTypeClass] = true; + } + + /** + * @return array, ISharePropertyType> + */ + #[\Override] + public function getPropertyTypes(): array { + foreach ($this->propertyTypes as $propertyType) { + if (!isset($this->propertyTypeCompatibleSourceTypes[$propertyType::class])) { + throw new RuntimeException('Share property type ' . $propertyType::class . ' has no compatible source types.'); + } + + if (!isset($this->propertyTypeCompatibleRecipientTypes[$propertyType::class])) { + throw new RuntimeException('Share property type ' . $propertyType::class . ' has no compatible recipient types.'); + } + } + + return $this->propertyTypes; + } + + #[\Override] + public function registerPermissionType(?string $sourceTypeClass, ISharePermissionType $permissionType): void { + $class = $permissionType::class; + if (isset($this->permissionTypes[$class])) { + throw new RuntimeException('Share permission type ' . $class . ' is already registered'); + } + + $this->permissionTypes[$class] = $permissionType; + $this->permissionTypeSourceType[$class] = $sourceTypeClass; + + if ($sourceTypeClass !== null) { + if (!isset($this->sourceTypes[$sourceTypeClass])) { + throw new RuntimeException('Share source type ' . $sourceTypeClass . ' is not registered'); + } + + $this->sourceTypePermissionTypes[$sourceTypeClass] ??= []; + $this->sourceTypePermissionTypes[$sourceTypeClass][] = $class; + } else { + $this->genericPermissionTypes[] = $class; + } + } + + /** + * @return array, ISharePermissionType> + */ + #[\Override] + public function getPermissionTypes(): array { + return $this->permissionTypes; + } + + /** + * @return array, ?class-string> + */ + #[\Override] + public function getPermissionTypeSourceTypeClass(): array { + return $this->permissionTypeSourceType; + } + + /** + * @return array, list>> + */ + #[\Override] + public function getSourceTypePermissionTypeClasses(): array { + return $this->sourceTypePermissionTypes; + } + + /** + * @return list> + */ + #[\Override] + public function getGenericPermissionTypeClasses(): array { + return $this->genericPermissionTypes; + } + + #[\Override] + public function registerPermissionPreset(ISharePermissionPreset $permissionPreset): void { + $class = $permissionPreset::class; + if (isset($this->permissionPresets[$class])) { + throw new RuntimeException('Share permission preset ' . $class . ' is already registered'); + } + + $this->permissionPresets[$class] = $permissionPreset; + } + + /** + * @return array, ISharePermissionPreset> + */ + #[\Override] + public function getPermissionPresets(): array { + return $this->permissionPresets; + } + + #[\Override] + public function markPermissionTypeCompatibleWithPermissionPreset(string $permissionTypeClass, string $permissionPresetClass): void { + // Because we can't control the order in which apps are booted, we can't ensure that the permission type and the permission preset are already registered. + $this->permissionTypeCompatiblePermissionPresets[$permissionTypeClass] ??= []; + $this->permissionTypeCompatiblePermissionPresets[$permissionTypeClass][$permissionPresetClass] = true; + $this->permissionPresetCompatiblePermissionTypes[$permissionPresetClass] ??= []; + $this->permissionPresetCompatiblePermissionTypes[$permissionPresetClass][$permissionTypeClass] = true; + } + + /** + * @return array, list>> + */ + #[\Override] + public function getPermissionTypeCompatiblePermissionPresetClasses(): array { + return array_map(function (array $permissionPresetClasses): array { + $permissionPresetClasses = array_keys($permissionPresetClasses); + foreach ($permissionPresetClasses as $permissionPresetClass) { + if (!isset($this->permissionPresets[$permissionPresetClass])) { + // Because we can't control the order in which apps are booted, we need to check now if it has been registered. + throw new RuntimeException('Share permission preset ' . $permissionPresetClass . ' is not registered'); + } + } + + return $permissionPresetClasses; + }, $this->permissionTypeCompatiblePermissionPresets); + } + + #[\Override] + public function getPermissionPresetCompatiblePermissionTypeClasses(): array { + $out = []; + foreach ($this->permissionPresetCompatiblePermissionTypes as $permissionPresetClass => $permissionTypeClasses) { + if ($permissionTypeClasses === []) { + throw new RuntimeException('Share permission preset ' . $permissionPresetClass . ' has no compatibl permission types.'); + } + + $permissionTypeClasses = array_keys($permissionTypeClasses); + foreach ($permissionTypeClasses as $permissionTypeClass) { + if (!isset($this->permissionTypes[$permissionTypeClass])) { + // Because we can't control the order in which apps are booted, we need to check now if it has been registered. + throw new RuntimeException('Share permission type ' . $permissionTypeClass . ' is not registered'); + } + } + + $out[$permissionPresetClass] = $permissionTypeClasses; + } + + return $out; + } +} diff --git a/lib/public/Sharing/Exception/AShareException.php b/lib/public/Sharing/Exception/AShareException.php new file mode 100644 index 0000000000000..d86e28c9965c9 --- /dev/null +++ b/lib/public/Sharing/Exception/AShareException.php @@ -0,0 +1,20 @@ +get('sharing')->t('Share not found.')); + } +} diff --git a/lib/public/Sharing/Exception/ShareOperationForbiddenException.php b/lib/public/Sharing/Exception/ShareOperationForbiddenException.php new file mode 100644 index 0000000000000..6c0d89dbc119e --- /dev/null +++ b/lib/public/Sharing/Exception/ShareOperationForbiddenException.php @@ -0,0 +1,27 @@ +get('sharing')->t('You are not allowed to edit this share.')); + } +} diff --git a/lib/public/Sharing/ISharingBackend.php b/lib/public/Sharing/ISharingBackend.php new file mode 100644 index 0000000000000..fbbd67e0fb2e4 --- /dev/null +++ b/lib/public/Sharing/ISharingBackend.php @@ -0,0 +1,192 @@ + + * @since 35.0.0 + */ + public function onSourceDeleted(ShareSource $source): array; + + /** + * Add a new recipient to a share. + * + * @throws ShareInvalidException + * @throws ShareNotFoundException + * @since 35.0.0 + */ + public function addShareRecipient(string $id, IUser $initiator, ShareRecipient $recipient): void; + + /** + * Remove an existing recipient from a share. + * + * @throws ShareNotFoundException + * @since 35.0.0 + */ + public function removeShareRecipient(string $id, ShareRecipient $recipient): void; + + /** + * Perform all updates when the recipient was deleted. + * + * @return list + * @since 35.0.0 + */ + public function onRecipientDeleted(ShareRecipient $recipient): array; + + /** + * Perform all updates when the initiator was deleted. + * + * @return list + * @since 35.0.0 + */ + public function onInitiatorDeleted(IUser $initiator): array; + + /** + * Update the scecret of a recipient. + * + * @throws ShareNotFoundException + * @since 35.0.0 + */ + public function updateShareRecipientSecret(string $id, ShareRecipient $recipient, string $secret): void; + + /** + * Update a property of a share. + * + * @throws ShareNotFoundException + * @since 35.0.0 + */ + public function updateShareProperty(string $id, ShareProperty $property): void; + + /** + * Update a permission of a share. + * + * @throws ShareNotFoundException + * @since 35.0.0 + */ + public function updateSharePermission(string $id, SharePermission $permission): void; + + /** + * Select a permission preset for a share. + * + * @param class-string $permissionPresetClass + * @throws ShareNotFoundException + * @since 35.0.0 + */ + public function selectSharePermissionPreset(string $id, string $permissionPresetClass): void; + + /** + * Delete a share. + * + * @throws ShareNotFoundException + * @since 35.0.0 + */ + public function deleteShare(string $id): void; + + /** + * Get a share. + * + * @throws ShareNotFoundException + * @since 35.0.0 + */ + public function getShare(ShareAccessContext $accessContext, string $id): Share; + + /** + * Get multiple shares. + * + * @param ?class-string $filterSourceTypeClass + * @param ?positive-int $limit + * @return list + * @since 35.0.0 + */ + public function getShares(ShareAccessContext $accessContext, ?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?string $lastShareID, ?int $limit): array; + + /** + * Check if a share ID belongs to this backend. + * + * @since 35.0.0 + */ + public function hasShare(string $id): bool; + + /** + * Get the owner of a share. + * + * @throws ShareNotFoundException + * @since 35.0.0 + */ + public function getShareOwner(string $id): ShareUser; + + /** + * Set the last updated timestamp for multiple shares. + * + * @param non-empty-list $ids + * @param non-negative-int $lastUpdated + * @throws ShareNotFoundException + * @since 35.0.0 + */ + public function setLastUpdated(array $ids, int $lastUpdated): void; +} diff --git a/lib/public/Sharing/ISharingManager.php b/lib/public/Sharing/ISharingManager.php new file mode 100644 index 0000000000000..74c35466a4899 --- /dev/null +++ b/lib/public/Sharing/ISharingManager.php @@ -0,0 +1,207 @@ +> $recipientTypeClasses + * @param positive-int $limit + * @param non-negative-int $offset + * @return list + * @since 35.0.0 + */ + public function searchRecipients(ShareAccessContext $accessContext, ?array $recipientTypeClasses, string $query, int $limit, int $offset): array; + + /** + * Generate a new secret. + * + * @return non-empty-string + * @since 35.0.0 + */ + public function generateSecret(): string; + + /** + * Generate a new timestamp in milliseconds since the UNIX epoch. + * + * @return non-negative-int + * @since 35.0.0 + */ + public function generateTimestamp(): int; + + /** + * Create a new share. + * + * @since 35.0.0 + */ + public function createShare(ShareAccessContext $accessContext): string; + + /** + * Perform all updates when the owner was deleted. + * + * @since 35.0.0 + */ + public function onOwnerDeleted(ShareAccessContext $accessContext, IUser $owner): void; + + /** + * Update the state of a share. + * + * @throws ShareInvalidException + * @throws ShareNotFoundException + * @throws ShareOperationForbiddenException + * @since 35.0.0 + */ + public function updateShareState(ShareAccessContext $accessContext, string $id, ShareState $state): void; + + /** + * Add a new source to a share. + * + * @throws ShareInvalidException + * @throws ShareNotFoundException + * @throws ShareOperationForbiddenException + * @since 35.0.0 + */ + public function addShareSource(ShareAccessContext $accessContext, string $id, ShareSource $source): void; + + /** + * Remove an existing source from a share. + * + * @throws ShareNotFoundException + * @throws ShareOperationForbiddenException + * @since 35.0.0 + */ + public function removeShareSource(ShareAccessContext $accessContext, string $id, ShareSource $source): void; + + /** + * Perform all updates when the source was deleted. + * + * @since 35.0.0 + */ + public function onSourceDeleted(ShareAccessContext $accessContext, ShareSource $source): void; + + /** + * Add a new recipient to a share. + * + * @throws ShareInvalidException + * @throws ShareNotFoundException + * @throws ShareOperationForbiddenException + * @since 35.0.0 + */ + public function addShareRecipient(ShareAccessContext $accessContext, string $id, ShareRecipient $recipient): void; + + /** + * Remove an existing recipient from a share. + * + * @throws ShareNotFoundException + * @throws ShareOperationForbiddenException + * @since 35.0.0 + */ + public function removeShareRecipient(ShareAccessContext $accessContext, string $id, ShareRecipient $recipient): void; + + /** + * Perform all updates when the recipient was deleted. + * + * @since 35.0.0 + */ + public function onRecipientDeleted(ShareAccessContext $accessContext, ShareRecipient $recipient): void; + + /** + * Perform all updates when the initiator was deleted. + * + * @since 35.0.0 + */ + public function onInitiatorDeleted(ShareAccessContext $accessContext, IUser $initiator): void; + + /** + * Update the scecret of a recipient. + * + * @throws ShareInvalidException + * @throws ShareNotFoundException + * @throws ShareOperationForbiddenException + * @since 35.0.0 + */ + public function updateShareRecipientSecret(ShareAccessContext $accessContext, string $id, ShareRecipient $recipient, string $secret): void; + + /** + * Update a property of a share. + * + * @throws ShareInvalidException + * @throws ShareNotFoundException + * @throws ShareOperationForbiddenException + * @since 35.0.0 + */ + public function updateShareProperty(ShareAccessContext $accessContext, string $id, ShareProperty $property): void; + + /** + * Update a permission of a share. + * + * @throws ShareInvalidException + * @throws ShareNotFoundException + * @throws ShareOperationForbiddenException + * @since 35.0.0 + */ + public function updateSharePermission(ShareAccessContext $accessContext, string $id, SharePermission $permission): void; + + /** + * Select a permission preset for a share. + * + * @param class-string $permissionPresetClass + * @throws ShareNotFoundException + * @throws ShareOperationForbiddenException + * @since 35.0.0 + */ + public function selectSharePermissionPreset(ShareAccessContext $accessContext, string $id, string $permissionPresetClass): void; + + /** + * Delete a share. + * + * @throws ShareNotFoundException + * @throws ShareOperationForbiddenException + * @since 35.0.0 + */ + public function deleteShare(ShareAccessContext $accessContext, string $id): void; + + /** + * Get a share. + * + * @throws ShareNotFoundException + * @since 35.0.0 + */ + public function getShare(ShareAccessContext $accessContext, string $id): Share; + + // TODO: Implement filtering by state. + /** + * Get multiple shares. + * + * @param ?class-string $filterSourceTypeClass + * @param ?positive-int $limit + * @return list + * @since 35.0.0 + */ + public function getShares(ShareAccessContext $accessContext, ?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?string $lastShareID, ?int $limit): array; +} diff --git a/lib/public/Sharing/ISharingRegistry.php b/lib/public/Sharing/ISharingRegistry.php new file mode 100644 index 0000000000000..e145df06c5feb --- /dev/null +++ b/lib/public/Sharing/ISharingRegistry.php @@ -0,0 +1,158 @@ +, ISharingBackend> + * @since 35.0.0 + */ + public function getSharingBackends(): array; + + /** + * @since 35.0.0 + */ + public function registerSourceType(IShareSourceType $sourceType): void; + + /** + * @return array, IShareSourceType> + * @since 35.0.0 + */ + public function getSourceTypes(): array; + + /** + * @return array, list>> + * @since 35.0.0 + */ + public function getPropertyTypeCompatibleSourceTypeClasses(): array; + + /** + * @since 35.0.0 + */ + public function registerRecipientType(IShareRecipientType $recipientType): void; + + /** + * @return array, IShareRecipientType> + * @since 35.0.0 + */ + public function getRecipientTypes(): array; + + /** + * @return array, list>> + * @since 35.0.0 + */ + public function getPropertyTypeCompatibleRecipientTypes(): array; + + /** + * @since 35.0.0 + */ + public function registerPropertyType(ISharePropertyType $propertyType): void; + + /** + * @param class-string $propertyTypeClass + * @param class-string $sourceTypeClass + * @since 35.0.0 + */ + public function markPropertyTypeCompatibleWithSourceType(string $propertyTypeClass, string $sourceTypeClass): void; + + /** + * @param class-string $propertyTypeClass + * @param class-string $recipientTypeClass + * @since 35.0.0 + */ + public function markPropertyTypeCompatibleWithRecipientType(string $propertyTypeClass, string $recipientTypeClass): void; + + /** + * @return array, ISharePropertyType> + * @since 35.0.0 + */ + public function getPropertyTypes(): array; + + /** + * @param class-string $sourceTypeClass + * @since 35.0.0 + */ + public function registerPermissionType(?string $sourceTypeClass, ISharePermissionType $permissionType): void; + + /** + * @return array, ISharePermissionType> + * @since 35.0.0 + */ + public function getPermissionTypes(): array; + + /** + * @return array, ?class-string> + * @since 35.0.0 + */ + public function getPermissionTypeSourceTypeClass(): array; + + /** + * @return array, list>> + * @since 35.0.0 + */ + public function getSourceTypePermissionTypeClasses(): array; + + /** + * @return list> + * @since 35.0.0 + */ + public function getGenericPermissionTypeClasses(): array; + + /** + * @since 35.0.0 + */ + public function registerPermissionPreset(ISharePermissionPreset $permissionPreset): void; + + /** + * @return array, ISharePermissionPreset> + * @since 35.0.0 + */ + public function getPermissionPresets(): array; + + /** + * @param class-string $permissionTypeClass + * @param class-string $permissionPresetClass + * @since 35.0.0 + */ + public function markPermissionTypeCompatibleWithPermissionPreset(string $permissionTypeClass, string $permissionPresetClass): void; + + /** + * @return array, list>> + * @since 35.0.0 + */ + public function getPermissionTypeCompatiblePermissionPresetClasses(): array; + + /** + * @return array, non-empty-list>> + * @since 35.0.0 + */ + public function getPermissionPresetCompatiblePermissionTypeClasses(): array; +} diff --git a/lib/public/Sharing/Icon/ShareIconSVG.php b/lib/public/Sharing/Icon/ShareIconSVG.php new file mode 100644 index 0000000000000..953f689ce7ce8 --- /dev/null +++ b/lib/public/Sharing/Icon/ShareIconSVG.php @@ -0,0 +1,43 @@ + $this->svg, + ]; + } +} diff --git a/lib/public/Sharing/Icon/ShareIconURL.php b/lib/public/Sharing/Icon/ShareIconURL.php new file mode 100644 index 0000000000000..51d235d9c5d0e --- /dev/null +++ b/lib/public/Sharing/Icon/ShareIconURL.php @@ -0,0 +1,50 @@ + $this->light, + 'dark' => $this->dark, + ]; + } +} diff --git a/lib/public/Sharing/Permission/ISharePermissionPreset.php b/lib/public/Sharing/Permission/ISharePermissionPreset.php new file mode 100644 index 0000000000000..54ff4e63f99e6 --- /dev/null +++ b/lib/public/Sharing/Permission/ISharePermissionPreset.php @@ -0,0 +1,35 @@ + + * @since 35.0.0 + */ + public function getPriority(): int; + + /** + * Whether this permission is enabled by default or not. + * + * @since 35.0.0 + */ + public function isEnabledByDefault(): bool; +} diff --git a/lib/public/Sharing/Permission/SharePermission.php b/lib/public/Sharing/Permission/SharePermission.php new file mode 100644 index 0000000000000..a14993e590a46 --- /dev/null +++ b/lib/public/Sharing/Permission/SharePermission.php @@ -0,0 +1,53 @@ + $class */ + public string $class, + public bool $enabled, + ) { + } + + /** + * @return SharingPermission + * @since 35.0.0 + */ + public function format(ISharingRegistry $registry, IFactory $l10nFactory): array { + if (($permissionType = ($registry->getPermissionTypes()[$this->class] ?? null)) === null) { + throw new RuntimeException('The permission type is not registered: ' . $this->class); + } + + return [ + 'class' => $this->class, + 'source_class' => $registry->getPermissionTypeSourceTypeClass()[$this->class], + 'display_name' => $permissionType->getDisplayName($l10nFactory), + 'hint' => $permissionType->getHint($l10nFactory), + 'priority' => $permissionType->getPriority(), + 'presets' => $registry->getPermissionTypeCompatiblePermissionPresetClasses()[$permissionType::class] ?? [], + 'enabled' => $this->enabled, + ]; + } +} diff --git a/lib/public/Sharing/Property/ABooleanSharePropertyType.php b/lib/public/Sharing/Property/ABooleanSharePropertyType.php new file mode 100644 index 0000000000000..7df7ff0e8005b --- /dev/null +++ b/lib/public/Sharing/Property/ABooleanSharePropertyType.php @@ -0,0 +1,46 @@ +get(Application::APP_ID)->t('Only true and false are valid values: ' . $value); + } + + /** + * @param SharingProperty $property + * @return SharingPropertyBoolean + * @since 35.0.0 + */ + #[\Override] + public function format(array $property): array { + $property['type'] = 'boolean'; + return $property; + } +} diff --git a/lib/public/Sharing/Property/ADateSharePropertyType.php b/lib/public/Sharing/Property/ADateSharePropertyType.php new file mode 100644 index 0000000000000..81afe690e1078 --- /dev/null +++ b/lib/public/Sharing/Property/ADateSharePropertyType.php @@ -0,0 +1,75 @@ +get(Application::APP_ID)->t('Invalid ISO date: ' . $value); + } + + if (($minDate = $this->getMinDate()) instanceof DateTimeImmutable && $date->diff($minDate)->invert === 0) { + return $l10nFactory->get(Application::APP_ID)->t('Date needs to be after ' . $minDate->format(DateTimeInterface::ATOM) . ': ' . $value); + } + + if (($maxDate = $this->getMaxDate()) instanceof DateTimeImmutable && $date->diff($maxDate)->invert === 1) { + return $l10nFactory->get(Application::APP_ID)->t('Date needs to be before ' . $maxDate->format(DateTimeInterface::ATOM) . ': ' . $value); + } + + return true; + } + + /** + * @param SharingProperty $property + * @return SharingPropertyDate + * @since 35.0.0 + */ + #[\Override] + public function format(array $property): array { + $property['type'] = 'date'; + $property['min_date'] = $this->getMinDate()?->format(DateTimeInterface::ATOM); + $property['max_date'] = $this->getMaxDate()?->format(DateTimeInterface::ATOM); + return $property; + } +} diff --git a/lib/public/Sharing/Property/AEnumSharePropertyType.php b/lib/public/Sharing/Property/AEnumSharePropertyType.php new file mode 100644 index 0000000000000..5f67bdb72fee5 --- /dev/null +++ b/lib/public/Sharing/Property/AEnumSharePropertyType.php @@ -0,0 +1,54 @@ + + * @since 35.0.0 + */ + abstract public function getValidValues(): array; + + /** + * @since 35.0.0 + */ + #[\Override] + public function validateValue(IFactory $l10nFactory, string $value): true|string { + $validValues = $this->getValidValues(); + if (in_array($value, $validValues, true)) { + return true; + } + + return $l10nFactory->get(Application::APP_ID)->t('Only ' . implode(', ', $validValues) . ' are valid values: ' . $value); + } + + /** + * @param SharingProperty $property + * @return SharingPropertyEnum + * @since 35.0.0 + */ + #[\Override] + public function format(array $property): array { + $property['type'] = 'enum'; + $property['valid_values'] = $this->getValidValues(); + return $property; + } +} diff --git a/lib/public/Sharing/Property/APasswordSharePropertyType.php b/lib/public/Sharing/Property/APasswordSharePropertyType.php new file mode 100644 index 0000000000000..f3215317ac0a4 --- /dev/null +++ b/lib/public/Sharing/Property/APasswordSharePropertyType.php @@ -0,0 +1,101 @@ +eventDispatcher ??= Server::get(IEventDispatcher::class); + } + + private function getHasher(): IHasher { + return $this->hasher ??= Server::get(IHasher::class); + } + + /** + * @since 35.0.0 + */ + #[\Override] + public function validateValue(IFactory $l10nFactory, string $value): true|string { + if ($value === self::PLACEHOLDER) { + return true; + } + + try { + $this->getEventDispatcher()->dispatchTyped(new ValidatePasswordPolicyEvent($value, PasswordContext::SHARING)); + return true; + } catch (HintException $hintException) { + return $hintException->getHint(); + } + } + + /** + * @since 35.0.0 + */ + #[\Override] + public function modifyValueOnSave(?string $oldValue, ?string $newValue): ?string { + if ($newValue === null) { + return null; + } + + if ($newValue === self::PLACEHOLDER) { + return $oldValue; + } + + return $this->getHasher()->hash($newValue); + } + + /** + * @since 35.0.0 + */ + #[\Override] + public function modifyValueOnLoad(?string $value): ?string { + if ($value === null) { + return null; + } + + return self::PLACEHOLDER; + } + + /** + * @param SharingProperty $property + * @return SharingPropertyPassword + * @since 35.0.0 + */ + #[\Override] + public function format(array $property): array { + $property['type'] = 'password'; + return $property; + } +} diff --git a/lib/public/Sharing/Property/AStringSharePropertyType.php b/lib/public/Sharing/Property/AStringSharePropertyType.php new file mode 100644 index 0000000000000..396b43e81f05a --- /dev/null +++ b/lib/public/Sharing/Property/AStringSharePropertyType.php @@ -0,0 +1,64 @@ +getMinLength()) !== null && mb_strlen($value) < $minLength) { + return $l10nFactory->get(Application::APP_ID)->t('Need at least ' . $minLength . ' characters: ' . $value); + } + + if (($maxLength = $this->getMaxLength()) !== null && mb_strlen($value) > $maxLength) { + return $l10nFactory->get(Application::APP_ID)->t('Provide ' . $maxLength . ' characters at most: ' . $value); + } + + return true; + } + + /** + * @param SharingProperty $property + * @return SharingPropertyString + * @since 35.0.0 + */ + #[\Override] + public function format(array $property): array { + $property['type'] = 'string'; + $property['min_length'] = $this->getMinLength(); + $property['max_length'] = $this->getMaxLength(); + return $property; + } +} diff --git a/lib/public/Sharing/Property/ISharePropertyType.php b/lib/public/Sharing/Property/ISharePropertyType.php new file mode 100644 index 0000000000000..3c978fb49de80 --- /dev/null +++ b/lib/public/Sharing/Property/ISharePropertyType.php @@ -0,0 +1,95 @@ + + * @since 35.0.0 + */ + public function getPriority(): int; + + /** + * Whether clients should show it in the advanced settings section. + * + * @since 35.0.0 + */ + public function isAdvanced(): bool; + + /** + * Whether this property is required to be set. + * + * @since 35.0.0 + */ + public function isRequired(): bool; + + /** + * The default value if the user hasn't provided one. + * + * A default value must be returned, if {@see self::isRequired()} returns true. + * + * @since 35.0.0 + */ + public function getDefaultValue(): ?string; + + /** + * Validates the value when the share is created or updated in the database. + * + * Returns a user friendly error message if the value is not valid. + * + * @since 35.0.0 + */ + public function validateValue(IFactory $l10nFactory, string $value): true|string; + + /** + * @param SharingProperty $property + * @return SharingPropertyBoolean|SharingPropertyDate|SharingPropertyEnum|SharingPropertyPassword|SharingPropertyString + * @since 35.0.0 + */ + public function format(array $property): array; +} diff --git a/lib/public/Sharing/Property/ISharePropertyTypeFilter.php b/lib/public/Sharing/Property/ISharePropertyTypeFilter.php new file mode 100644 index 0000000000000..cd8562698b534 --- /dev/null +++ b/lib/public/Sharing/Property/ISharePropertyTypeFilter.php @@ -0,0 +1,29 @@ + $class */ + public string $class, + public ?string $value, + ) { + } + + /** + * @return SharingPropertyBoolean|SharingPropertyDate|SharingPropertyEnum|SharingPropertyPassword|SharingPropertyString + * @since 35.0.0 + */ + public function format(ISharingRegistry $registry, IFactory $l10nFactory): array { + if (($propertyType = ($registry->getPropertyTypes()[$this->class] ?? null)) === null) { + throw new RuntimeException('The property type is not registered: ' . $this->class); + } + + return $propertyType->format([ + 'class' => $this->class, + 'display_name' => $propertyType->getDisplayName($l10nFactory), + 'hint' => $propertyType->getHint($l10nFactory), + 'priority' => $propertyType->getPriority(), + 'advanced' => $propertyType->isAdvanced(), + 'required' => $propertyType->isRequired(), + 'value' => $this->value, + ]); + } +} diff --git a/lib/public/Sharing/Recipient/AShareRecipientTypeSearchCollaborator.php b/lib/public/Sharing/Recipient/AShareRecipientTypeSearchCollaborator.php new file mode 100644 index 0000000000000..f4587d5baccff --- /dev/null +++ b/lib/public/Sharing/Recipient/AShareRecipientTypeSearchCollaborator.php @@ -0,0 +1,138 @@ +userSession ??= Server::get(IUserSession::class); + } + + private function getSearch(): ISearch { + return $this->search ??= Server::get(ISearch::class); + } + + /** + * @return IShare::TYPE_* + * @since 35.0.0 + */ + abstract public function getCollaboratorType(): int; + + /** + * @since 35.0.0 + */ + abstract public function getCollaboratorKey(): string; + + /** + * Search for recipients. + * + * @param positive-int $limit + * @param non-negative-int $offset + * @return list + * @since 35.0.0 + */ + #[\Override] + public function searchRecipients(ShareAccessContext $accessContext, string $query, int $limit, int $offset): array { + // Many collaborator plugins require a user session, so we abort early to avoid crashes. + if (!$accessContext->currentUser instanceof IUser) { + return []; + } + + if ($this->getUserSession()->getUser()?->getUID() !== $accessContext->currentUser->getUID()) { + // Avoid mixing up users + return []; + } + + // TODO: Maybe enable lookup? + // TODO: Maybe merge search requests from different recipient types backed by the collaborators API. + $searchResults = $this->getSearch()->search($query, [$this->getCollaboratorType()], false, $limit, $offset); + if ($searchResults === []) { + return []; + } + + /** @var mixed $searchResults */ + $searchResults = $searchResults[0]; + if (!is_array($searchResults)) { + return []; + } + + $results = []; + if (($exactResults = $searchResults['exact']) !== null) { + if (!is_array($exactResults)) { + throw new RuntimeException('The exact results are not an array.'); + } + + if (($exactCollaboratorResults = $exactResults[$this->getCollaboratorKey()] ?? null) !== null) { + if (!is_array($exactCollaboratorResults) || !array_is_list($exactCollaboratorResults)) { + throw new RuntimeException('The exact collaborator results are not an array.'); + } + + $results[] = $exactCollaboratorResults; + } + } + + if (($collaboratorResults = $searchResults[$this->getCollaboratorKey()] ?? null) !== null) { + if (!is_array($collaboratorResults) || !array_is_list($collaboratorResults)) { + throw new RuntimeException('The collaborator results are not an array.'); + } + + $results[] = $collaboratorResults; + } + + if ($results === []) { + return []; + } + + return array_map(function (array $result): ShareRecipient { + if (!isset($result['value'])) { + throw new RuntimeException('The value is missing.'); + } + + if (!is_array($result['value'])) { + throw new RuntimeException('The value is not an array.'); + } + + if (!isset($result['value']['shareWith'])) { + throw new RuntimeException('The shareWith is missing.'); + } + + if (!is_string($result['value']['shareWith'])) { + throw new RuntimeException('The shareWith is not a string.'); + } + + if ($result['value']['shareWith'] === '') { + throw new RuntimeException('The shareWith is empty.'); + } + + return new ShareRecipient( + // Must be $this and not self, so the inheriting class is used. + static::class, + $result['value']['shareWith'], + null, + ); + }, array_merge(...$results)); + } +} diff --git a/lib/public/Sharing/Recipient/IShareRecipientType.php b/lib/public/Sharing/Recipient/IShareRecipientType.php new file mode 100644 index 0000000000000..6d3627f98d7d9 --- /dev/null +++ b/lib/public/Sharing/Recipient/IShareRecipientType.php @@ -0,0 +1,68 @@ + + * @since 35.0.0 + */ + public function getRecipients(?IUser $currentUser, mixed $arguments): array; + + /** + * @param non-empty-string $recipient + * @return ?non-empty-string + * @since 35.0.0 + */ + public function getRecipientDisplayName(string $recipient): ?string; + + /** + * @param non-empty-string $recipient + * @since 35.0.0 + */ + public function getRecipientIcon(string $recipient): null|ShareIconSVG|ShareIconURL; + + /** + * @param non-empty-string $recipient + * @since 35.0.0 + */ + public function getRecipientInteractionReceiver(string $recipient): InteractionReceiver; +} diff --git a/lib/public/Sharing/Recipient/IShareRecipientTypePublicSecret.php b/lib/public/Sharing/Recipient/IShareRecipientTypePublicSecret.php new file mode 100644 index 0000000000000..59972d4567b66 --- /dev/null +++ b/lib/public/Sharing/Recipient/IShareRecipientTypePublicSecret.php @@ -0,0 +1,30 @@ + + * @since 35.0.0 + */ + public function searchRecipients(ShareAccessContext $accessContext, string $query, int $limit, int $offset): array; +} diff --git a/lib/public/Sharing/Recipient/ShareRecipient.php b/lib/public/Sharing/Recipient/ShareRecipient.php new file mode 100644 index 0000000000000..3181700f2afd9 --- /dev/null +++ b/lib/public/Sharing/Recipient/ShareRecipient.php @@ -0,0 +1,103 @@ + $class */ + public string $class, + /** @var non-empty-string $value */ + public string $value, + /** @var ?non-empty-string $instance */ + public ?string $instance, + /** @var ?non-empty-string $secret */ + public ?string $secret = null, + public ?ShareUser $initiator = null, + ) { + if ($instance !== null && !preg_match('/^https?:\/\/.+/', $instance)) { + throw new RuntimeException('The instance is not a valid absolute URL: ' . $instance); + } + } + + /** + * @return SharingRecipient + * @since 35.0.0 + */ + public function format(ISharingRegistry $registry, IFactory $l10nFactory, IURLGenerator $urlGenerator, IUserManager $userManager, bool $isUnique): array { + if (($recipientType = ($registry->getRecipientTypes()[$this->class] ?? null)) === null) { + throw new RuntimeException('The recipient type is not registered: ' . $this->class); + } + + $displayName = $recipientType->getRecipientDisplayName($this->value) ?? $this->value; + if (!$isUnique) { + $displayName .= ' (' . $recipientType->getDisplayName($l10nFactory) . ': ' . $this->value . ')'; + } + + $secret = [ + 'updatable' => $recipientType instanceof IShareRecipientTypePublicSecret && $recipientType->isSecretUpdatable($this->value), + ]; + if ($this->secret !== null && $recipientType instanceof IShareRecipientTypePublicSecret && $recipientType->isSecretPublic($this->value)) { + $secret['value'] = $this->secret; + + // TODO: We need a dedicated page that shows all sources linked to a share + $secret['url'] = $urlGenerator->linkToRouteAbsolute( + 'files_sharing.sharecontroller.showShare', + [ + 'token' => $this->secret, + ], + ); + } + + return [ + 'class' => $this->class, + 'value' => $this->value, + 'instance' => $this->instance, + 'display_name' => $displayName, + 'icon' => $recipientType->getRecipientIcon($this->value)?->format(), + 'secret' => $secret, + 'initiator' => $this->initiator?->format($userManager), + ]; + } + + /** + * @param list $recipients + * @return list + * @since 35.0.0 + */ + public static function formatMultiple(ISharingRegistry $registry, IFactory $l10nFactory, IURLGenerator $urlGenerator, IUserManager $userManager, array $recipients): array { + $recipientTypes = $registry->getRecipientTypes(); + + $recipientDisplayNames = []; + foreach ($recipients as $recipient) { + $displayName = $recipientTypes[$recipient->class]?->getRecipientDisplayName($recipient->value) ?? $recipient->value; + $recipientDisplayNames[$displayName] ??= 0; + ++$recipientDisplayNames[$displayName]; + } + + return array_map(static fn (ShareRecipient $recipient): array => $recipient->format($registry, $l10nFactory, $urlGenerator, $userManager, $recipientDisplayNames[$recipientTypes[$recipient->class]?->getRecipientDisplayName($recipient->value) ?? $recipient->value] === 1), $recipients); + } +} diff --git a/lib/public/Sharing/Share.php b/lib/public/Sharing/Share.php new file mode 100644 index 0000000000000..f445473fab1c1 --- /dev/null +++ b/lib/public/Sharing/Share.php @@ -0,0 +1,260 @@ +, + * value: non-empty-string, + * display_name: non-empty-string, + * icon: ?SharingIcon, + * } + * + * @psalm-type SharingUser = array{ + * user_id: non-empty-string, + * instance: ?non-empty-string, + * display_name: non-empty-string, + * icon: SharingIcon, + * } + * + * @psalm-type SharingRecipient = array{ + * class: class-string, + * value: non-empty-string, + * instance: ?non-empty-string, + * display_name: non-empty-string, + * icon: ?SharingIcon, + * secret: array{ + * updatable: bool, + * value?: non-empty-string, + * url?: non-empty-string, + * }, + * initiator: ?SharingUser, + * } + * + * @psalm-type SharingState = 'active'|'draft'|'deleted' + * + * @psalm-type SharingProperty = array{ + * class: class-string, + * display_name: non-empty-string, + * hint: ?non-empty-string, + * priority: int<1, 100>, + * required: bool, + * advanced: bool, + * value: ?string, + * } + * + * @psalm-type SharingPropertyBoolean = SharingProperty&array{ + * type: 'boolean', + * } + * + * @psalm-type SharingPropertyDate = SharingProperty&array{ + * type: 'date', + * // ISO 8601 + * min_date: ?non-empty-string, + * // ISO 8601 + * max_date: ?non-empty-string, + * } + * + * @psalm-type SharingPropertyEnum = SharingProperty&array{ + * type: 'enum', + * valid_values: non-empty-list, + * } + * + * @psalm-type SharingPropertyPassword = SharingProperty&array{ + * type: 'password', + * } + * + * @psalm-type SharingPropertyString = SharingProperty&array{ + * type: 'string', + * min_length: ?positive-int, + * max_length: ?positive-int, + * } + * + * @psalm-type SharingPermissionPreset = array{ + * class: class-string, + * display_name: non-empty-string, + * hint: ?non-empty-string, + * } + * + * @psalm-type SharingPermission = array{ + * class: class-string, + * source_class: ?class-string, + * display_name: non-empty-string, + * hint: ?non-empty-string, + * priority: int<1, 100>, + * presets: list>, + * enabled: bool, + * } + * + * @psalm-type SharingSourceType = array{ + * class: class-string, + * } + * + * @psalm-type SharingShare = array{ + * id: non-empty-string, + * owner: SharingUser, + * // Unix time in milliseconds + * last_updated: non-negative-int, + * state: SharingState, + * sources: list, + * recipients: list, + * properties: list, + * permissions: list, + * permission_preset: ?class-string, + * } + * + * @since 35.0.0 + */ +#[Consumable(since: '35.0.0')] +final class Share { + /** @var array, SharePermission> $enabledPermissions */ + private ?array $enabledPermissions = null; + + /** + * @since 35.0.0 + */ + public function __construct( + /** @var non-empty-string $id */ + public readonly string $id, + public readonly ShareUser $owner, + /** @var non-negative-int $lastUpdated Unix time in milliseconds */ + public readonly int $lastUpdated, + public readonly ShareState $state, + /** @var list $sources */ + public readonly array $sources, + /** @var list $recipients */ + public readonly array $recipients, + /** @var array, ShareProperty> $properties */ + public readonly array $properties, + /** @var array, SharePermission> $permissions */ + public readonly array $permissions, + ) { + } + + /** + * @return array, SharePermission> + * @since 35.0.0 + */ + public function getEnabledPermissions(): array { + return $this->enabledPermissions ??= array_filter($this->permissions, static fn (SharePermission $permission): bool => $permission->enabled); + } + + /** + * @return SharingShare + * @since 35.0.0 + */ + public function format(ISharingRegistry $registry, IFactory $l10nFactory, IURLGenerator $urlGenerator, IUserManager $userManager): array { + $properties = array_map(static fn (ShareProperty $property): array => $property->format($registry, $l10nFactory), array_values($this->properties)); + usort($properties, static fn (array $a, array $b): int => $b['priority'] <=> $a['priority']); + + $registrySourceTypePermissionTypeClasses = $registry->getSourceTypePermissionTypeClasses(); + $registryGenericPermissionTypeClasses = $registry->getGenericPermissionTypeClasses(); + $registryPermissionTypeCompatiblePermissionPresetClasses = $registry->getPermissionTypeCompatiblePermissionPresetClasses(); + + /** @var array, bool> $compatiblePermissionTypeClasses */ + $compatiblePermissionTypeClasses = []; + foreach ($registryGenericPermissionTypeClasses as $permissionTypeClass) { + $compatiblePermissionTypeClasses[$permissionTypeClass] = true; + } + + foreach ($this->sources as $source) { + if (isset($registrySourceTypePermissionTypeClasses[$source->class])) { + foreach ($registrySourceTypePermissionTypeClasses[$source->class] as $permissionTypeClass) { + $compatiblePermissionTypeClasses[$permissionTypeClass] = true; + } + } + } + + $selectedPermissionPresetClass = null; + + $enabledPermissionTypeClasses = array_values(array_map(static fn (SharePermission $permission): string => $permission->class, $this->getEnabledPermissions())); + sort($enabledPermissionTypeClasses); + + $requiredPermissionTypeClasses = []; + foreach ($registry->getPermissionTypes() as $permissionType) { + // Only consider permissions that are compatible with the sources. + if (!isset($compatiblePermissionTypeClasses[$permissionType::class])) { + continue; + } + + foreach ($registryPermissionTypeCompatiblePermissionPresetClasses[$permissionType::class] ?? [] as $permissionPresetClass) { + $requiredPermissionTypeClasses[$permissionPresetClass][] = $permissionType::class; + } + } + + foreach ($requiredPermissionTypeClasses as $permissionPresetClass => $requiredPermissions) { + if (count($enabledPermissionTypeClasses) !== count($requiredPermissions)) { + continue; + } + + sort($requiredPermissions); + + if ($enabledPermissionTypeClasses === $requiredPermissions) { + $selectedPermissionPresetClass = $permissionPresetClass; + break; + } + } + + $permissions = array_map(static fn (SharePermission $permission): array => $permission->format($registry, $l10nFactory), array_values($this->permissions)); + usort($permissions, static fn (array $a, array $b): int => $b['priority'] <=> $a['priority']); + + return [ + 'id' => $this->id, + 'owner' => $this->owner->format($userManager), + 'last_updated' => $this->lastUpdated, + 'state' => $this->state->value, + 'sources' => ShareSource::formatMultiple($registry, $l10nFactory, $this->sources), + 'recipients' => ShareRecipient::formatMultiple($registry, $l10nFactory, $urlGenerator, $userManager, $this->recipients), + 'properties' => $properties, + 'permissions' => $permissions, + 'permission_preset' => $selectedPermissionPresetClass, + ]; + } + + /** + * @param list $shares + * @return list + * @since 35.0.0 + */ + public static function formatMultiple(ISharingRegistry $registry, IFactory $l10nFactory, IURLGenerator $urlGenerator, IUserManager $userManager, array $shares): array { + usort($shares, static fn (Share $a, Share $b): int => count($b->getEnabledPermissions()) <=> count($a->getEnabledPermissions())); + return array_map(static fn (Share $share): array => $share->format($registry, $l10nFactory, $urlGenerator, $userManager), $shares); + } +} diff --git a/lib/public/Sharing/ShareAccessContext.php b/lib/public/Sharing/ShareAccessContext.php new file mode 100644 index 0000000000000..402433ae5f90d --- /dev/null +++ b/lib/public/Sharing/ShareAccessContext.php @@ -0,0 +1,36 @@ +, mixed> $arguments */ + public array $arguments = [], + /** + * Ignore all checks and allow any operation. Only use it for admins and occ. + */ + public bool $overrideChecks = false, + ) { + } +} diff --git a/lib/public/Sharing/ShareState.php b/lib/public/Sharing/ShareState.php new file mode 100644 index 0000000000000..62edbeb5a7ce1 --- /dev/null +++ b/lib/public/Sharing/ShareState.php @@ -0,0 +1,31 @@ +instance === null && $accessContext->currentUser?->getUID() === $this->userId; + } + + /** + * @return SharingUser + * @since 35.0.0 + */ + public function format(IUserManager $userManager): array { + $ownerUser = $userManager->get($this->userId); + if ($ownerUser === null) { + throw new RuntimeException('The userId does not exist: ' . $this->userId); + } + + return [ + 'user_id' => $this->userId, + 'instance' => $this->instance, + 'display_name' => $ownerUser->getDisplayName(), + 'icon' => (new ShareIconURL( + $userManager->getAvatarUrlLight($this->userId, 64), + $userManager->getAvatarUrlDark($this->userId, 64), + ))->format(), + ]; + } +} diff --git a/lib/public/Sharing/Source/IShareSourceType.php b/lib/public/Sharing/Source/IShareSourceType.php new file mode 100644 index 0000000000000..a64500b8fb8d6 --- /dev/null +++ b/lib/public/Sharing/Source/IShareSourceType.php @@ -0,0 +1,60 @@ + $class */ + public string $class, + /** @var non-empty-string $value */ + public string $value, + ) { + } + + /** + * @return SharingSource + * @since 35.0.0 + */ + public function format(ISharingRegistry $registry, IFactory $l10nFactory, bool $isUnique): array { + if (($sourceType = ($registry->getSourceTypes()[$this->class] ?? null)) === null) { + throw new RuntimeException('The source type is not registered: ' . $this->class); + } + + $displayName = $sourceType->getSourceDisplayName($this->value) ?? $this->value; + if (!$isUnique) { + $displayName .= ' (' . $sourceType->getDisplayName($l10nFactory) . ': ' . $this->value . ')'; + } + + return [ + 'class' => $this->class, + 'value' => $this->value, + 'display_name' => $displayName, + 'icon' => $sourceType->getSourceIcon($this->value)?->format(), + ]; + } + + /** + * @param list $sources + * @return list + * @since 35.0.0 + */ + public static function formatMultiple(ISharingRegistry $registry, IFactory $l10nFactory, array $sources): array { + $sourceTypes = $registry->getSourceTypes(); + + $sourceDisplayNames = []; + foreach ($sources as $source) { + $displayName = $sourceTypes[$source->class]?->getSourceDisplayName($source->value) ?? $source->value; + $sourceDisplayNames[$displayName] ??= 0; + ++$sourceDisplayNames[$displayName]; + } + + return array_map(static fn (ShareSource $source): array => $source->format($registry, $l10nFactory, $sourceDisplayNames[$sourceTypes[$source->class]?->getSourceDisplayName($source->value) ?? $source->value] === 1), $sources); + } +} diff --git a/psalm-strict.xml b/psalm-strict.xml index 4fdfb7be2175c..5ead4b4b4684c 100644 --- a/psalm-strict.xml +++ b/psalm-strict.xml @@ -45,6 +45,9 @@ + + + diff --git a/psalm.xml b/psalm.xml index e158c47bb69a7..64238f00f6d6b 100644 --- a/psalm.xml +++ b/psalm.xml @@ -105,6 +105,7 @@ + diff --git a/tests/lib/Sharing/AbstractSharingManagerTests.php b/tests/lib/Sharing/AbstractSharingManagerTests.php new file mode 100644 index 0000000000000..19afac48a8a8a --- /dev/null +++ b/tests/lib/Sharing/AbstractSharingManagerTests.php @@ -0,0 +1,3910 @@ +dbConnection = Server::get(IDBConnection::class); + + $this->manager = Server::get(ISharingManager::class); + + $userManager = Server::get(IUserManager::class); + + $owner = $userManager->createUser('owner', 'password'); + $this->assertNotFalse($owner); + $this->owner = $owner; + $this->owner->setDisplayName('Owner'); + + $user1 = $userManager->createUser('user1', 'password'); + $this->assertNotFalse($user1); + $this->user1 = $user1; + $this->user1->setDisplayName('User 1'); + + $user2 = $userManager->createUser('user2', 'password'); + $this->assertNotFalse($user2); + $this->user2 = $user2; + $this->user2->setDisplayName('User 2'); + + $this->registry = Server::get(ISharingRegistry::class); + $this->registry->clear(); + $this->registry->registerSharingBackend(Server::get(SharingBackend::class)); + $this->registry->registerSourceType(new TestShareSourceType1(['source1' => 'Source 1'])); + $this->registry->registerSourceType(new TestShareSourceType2(['source2' => 'Source 2'])); + $this->registry->registerRecipientType(new TestShareRecipientType1( + [ + 'recipient1' => 'Recipient 1', + ], + [ + $this->user1->getUID() => ['recipient1'], + ], + [], + )); + $this->registry->registerRecipientType(new TestShareRecipientType2( + [ + 'recipient2' => 'Recipient 2', + ], + [ + $this->user2->getUID() => ['recipient2'], + ], + [], + )); + $this->registry->registerPropertyType(new TestSharePropertyType1(['valid1'])); + $this->registry->markPropertyTypeCompatibleWithSourceType(TestSharePropertyType1::class, TestShareSourceType1::class); + $this->registry->markPropertyTypeCompatibleWithRecipientType(TestSharePropertyType1::class, TestShareRecipientType1::class); + $this->registry->registerPropertyType(new TestSharePropertyType2(['valid2'])); + $this->registry->markPropertyTypeCompatibleWithSourceType(TestSharePropertyType2::class, TestShareSourceType2::class); + $this->registry->markPropertyTypeCompatibleWithRecipientType(TestSharePropertyType2::class, TestShareRecipientType2::class); + $this->registry->registerPermissionPreset(new TestSharePermissionPreset1()); + $this->registry->registerPermissionPreset(new TestSharePermissionPreset2()); + $this->registry->registerPermissionType(TestShareSourceType1::class, new TestSharePermissionType1()); + $this->registry->markPermissionTypeCompatibleWithPermissionPreset(TestSharePermissionType1::class, TestSharePermissionPreset1::class); + $this->registry->markPermissionTypeCompatibleWithPermissionPreset(TestSharePermissionType1::class, TestSharePermissionPreset2::class); + $this->registry->registerPermissionType(TestShareSourceType2::class, new TestSharePermissionType2()); + $this->registry->markPermissionTypeCompatibleWithPermissionPreset(TestSharePermissionType2::class, TestSharePermissionPreset2::class); + $this->registry->registerPermissionType(null, new ReshareSharePermissionType()); + } + + #[\Override] + protected function tearDown(): void { + if ($this->dbConnection->inTransaction()) { + $this->dbConnection->rollBack(); + $this->fail('Open transaction was not committed.'); + } + + $accessContext = new ShareAccessContext(overrideChecks: true); + + $this->dbConnection->beginTransaction(); + + foreach ($this->manager->getShares($accessContext, null, null, null, null) as $share) { + $this->manager->deleteShare($accessContext, $share->id); + } + + $this->owner->delete(); + $this->user1->delete(); + $this->user2->delete(); + + $this->dbConnection->commit(); + + foreach ([ + 'sharing_share', + 'sharing_share_permissions', + 'sharing_share_properties', + 'sharing_share_recipients', + 'sharing_share_sources', + ] as $table) { + $qb = $this->dbConnection->getQueryBuilder(); + $qb + ->select($qb->func()->count('*')) + ->from($table); + $this->assertEquals(0, $qb->executeQuery()->fetchOne(), $table); + } + + $this->registry->clear(); + + parent::tearDown(); + } + + public function testSearchRecipients(): void { + $this->registry->clear(); + $this->registry->registerRecipientType(new TestShareRecipientType1( + [ + 'recipient1a' => 'Recipient 1A', + 'recipient1b' => 'Recipient 1B', + 'recipient1c' => 'Recipient 1C', + ], + [], + [ + new ShareRecipient(TestShareRecipientType1::class, 'recipient1a', null), + new ShareRecipient(TestShareRecipientType1::class, 'recipient1b', null), + new ShareRecipient(TestShareRecipientType1::class, 'recipient1c', null), + ], + )); + $this->registry->registerRecipientType(new TestShareRecipientType2( + [ + 'recipient2a' => 'Recipient 2A', + 'recipient2b' => 'Recipient 2B', + 'recipient2c' => 'Recipient 2C', + ], + [], + [ + new ShareRecipient(TestShareRecipientType2::class, 'recipient2a', null), + new ShareRecipient(TestShareRecipientType2::class, 'recipient2b', null), + new ShareRecipient(TestShareRecipientType2::class, 'recipient2c', null), + ], + )); + + $accessContext = new ShareAccessContext($this->owner); + + $this->assertEquals([ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1a', + 'instance' => null, + 'display_name' => 'Recipient 1A', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1b', + 'instance' => null, + 'display_name' => 'Recipient 1B', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1c', + 'instance' => null, + 'display_name' => 'Recipient 1C', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + [ + 'class' => TestShareRecipientType2::class, + 'value' => 'recipient2a', + 'instance' => null, + 'display_name' => 'Recipient 2A', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + [ + 'class' => TestShareRecipientType2::class, + 'value' => 'recipient2b', + 'instance' => null, + 'display_name' => 'Recipient 2B', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + [ + 'class' => TestShareRecipientType2::class, + 'value' => 'recipient2c', + 'instance' => null, + 'display_name' => 'Recipient 2C', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + ], $this->searchRecipients($accessContext, null, 'recipient', 10, 0)); + + $this->assertEquals([ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1a', + 'instance' => null, + 'display_name' => 'Recipient 1A', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1b', + 'instance' => null, + 'display_name' => 'Recipient 1B', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1c', + 'instance' => null, + 'display_name' => 'Recipient 1C', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + ], $this->searchRecipients($accessContext, [TestShareRecipientType1::class], 'recipient', 10, 0)); + + $this->assertEquals([ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1a', + 'instance' => null, + 'display_name' => 'Recipient 1A', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + ], $this->searchRecipients($accessContext, [TestShareRecipientType1::class], 'recipient', 1, 0)); + + $this->assertEquals([ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1b', + 'instance' => null, + 'display_name' => 'Recipient 1B', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1c', + 'instance' => null, + 'display_name' => 'Recipient 1C', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + ], $this->searchRecipients($accessContext, [TestShareRecipientType1::class], 'recipient', 10, 1)); + } + + public function testSearchRecipientsUniqueDisplayNames(): void { + $this->registry->clear(); + $this->registry->registerRecipientType(new TestShareRecipientType1( + [ + 'recipient1' => 'Recipient', + ], + [], + [ + new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null), + ], + )); + $this->registry->registerRecipientType(new TestShareRecipientType2( + [ + 'recipient2' => 'Recipient', + 'recipient3' => 'Other', + ], + [], + [ + new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null), + new ShareRecipient(TestShareRecipientType2::class, 'recipient3', null), + ], + )); + + $accessContext = new ShareAccessContext($this->owner); + + $this->assertEquals([ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient (TestShareRecipientType1: recipient1)', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + [ + 'class' => TestShareRecipientType2::class, + 'value' => 'recipient2', + 'instance' => null, + 'display_name' => 'Recipient (TestShareRecipientType2: recipient2)', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + [ + 'class' => TestShareRecipientType2::class, + 'value' => 'recipient3', + 'instance' => null, + 'display_name' => 'Other', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + ], $this->searchRecipients($accessContext, null, 'recipient', 10, 0)); + } + + public function testSearchRecipientsIcons(): void { + $this->registry->clear(); + $this->registry->registerRecipientType(new TestShareRecipientType1( + [ + 'svg' => 'SVG', + 'url' => 'URL', + ], + [], + [ + new ShareRecipient(TestShareRecipientType1::class, 'svg', null), + new ShareRecipient(TestShareRecipientType1::class, 'url', null), + ], + )); + + $accessContext = new ShareAccessContext($this->owner); + + $this->assertEquals([ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'svg', + 'instance' => null, + 'display_name' => 'SVG', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'url', + 'instance' => null, + 'display_name' => 'URL', + 'icon' => [ + 'light' => 'https://example.com/light.png', + 'dark' => 'https://example.com/dark.png', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => null, + ], + ], $this->searchRecipients($accessContext, null, 'icon', 10, 0)); + } + + public function testCreateShare(): void { + $accessContext = new ShareAccessContext($this->owner); + + $before = $this->manager->generateTimestamp(); + $share = $this->createShare($accessContext); + $after = $this->manager->generateTimestamp(); + unset($share['id']); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + unset($share['last_updated']); + $this->assertEquals([ + 'owner' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + 'state' => ShareState::Draft->value, + 'sources' => [], + 'recipients' => [], + 'properties' => [], + 'permissions' => [ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + ], + 'permission_preset' => null, + ], $share); + } + + /** + * @return list, list, list, list, ?string}> + */ + public static function dataProviderUpdateShareState(): array { + return [ + [ + [new ShareSource(TestShareSourceType1::class, 'source1')], + [new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)], + [new ShareProperty(TestSharePropertyTypeRequired::class, 'valid1')], + [new SharePermission(ReshareSharePermissionType::class, true)], + null, + ], + [ + [], + [new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)], + [], + [new SharePermission(ReshareSharePermissionType::class, true)], + 'You need to add at least one source to make the share available.', + ], + [ + [new ShareSource(TestShareSourceType1::class, 'source1')], + [], + [], + [new SharePermission(ReshareSharePermissionType::class, true)], + 'You need to add at least one recipient to make the share available.', + ], + [ + [new ShareSource(TestShareSourceType1::class, 'source1')], + [new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)], + [new ShareProperty(TestSharePropertyTypeRequired::class, null)], + [new SharePermission(ReshareSharePermissionType::class, true)], + 'You need to set a value for the TestSharePropertyTypeRequired', + ], + [ + [new ShareSource(TestShareSourceType1::class, 'source1')], + [new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)], + [new ShareProperty(TestSharePropertyTypeRequired::class, 'valid1')], + [new SharePermission(ReshareSharePermissionType::class, false)], + 'You need to allow at least one permission to make the share available.', + ], + ]; + } + + /** + * @param list $sources + * @param list $recipients + * @param list $properties + * @param list $permissions + */ + #[DataProvider('dataProviderUpdateShareState')] + public function testUpdateShareState(array $sources, array $recipients, array $properties, array $permissions, ?string $errorMessage): void { + $this->registry->registerPropertyType(new TestSharePropertyTypeRequired(['valid1'])); + $this->registry->markPropertyTypeCompatibleWithSourceType(TestSharePropertyTypeRequired::class, TestShareSourceType1::class); + $this->registry->markPropertyTypeCompatibleWithRecipientType(TestSharePropertyTypeRequired::class, TestShareRecipientType1::class); + + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + foreach ($sources as $source) { + $this->manager->addShareSource($accessContext, $id, $source); + } + + foreach ($recipients as $recipient) { + $this->manager->addShareRecipient($accessContext, $id, $recipient); + } + + $this->manager->getShare($accessContext, $id); + + foreach ($properties as $property) { + $this->manager->updateShareProperty($accessContext, $id, $property); + } + + foreach ($permissions as $permission) { + $this->manager->updateSharePermission($accessContext, $id, $permission); + } + + $this->dbConnection->commit(); + + if ($errorMessage !== null) { + try { + $this->updateShareState($accessContext, $id, ShareState::Active); + $this->fail('Allowed to set share state active.'); + } catch (HintException $exception) { + $this->assertEquals($errorMessage, $exception->getHint()); + } + } else { + $before = $this->manager->generateTimestamp(); + $share = $this->updateShareState($accessContext, $id, ShareState::Active); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals(ShareState::Active->value, $share['state']); + } + } + + public function testAddShareSource(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->dbConnection->commit(); + + $before = $this->manager->generateTimestamp(); + $share = $this->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals([ + [ + 'class' => TestShareSourceType1::class, + 'value' => 'source1', + 'display_name' => 'Source 1', + 'icon' => [ + 'svg' => '', + ], + ], + ], $share ['sources']); + } + + public function testAddShareSourceInteractionRestricted(): void { + $listener = function (RestrictInteractionEvent $event): void { + foreach ($event->resources as $resource) { + if ($resource instanceof TestInteractionResource && $resource->getID() === 'source1') { + throw new InteractionRestrictedException('Source not allowed.', 'You are not allowed to add this source.'); + } + } + }; + $eventDispatcher = Server::get(IEventDispatcher::class); + $eventDispatcher->addListener(RestrictInteractionEvent::class, $listener); + + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->dbConnection->commit(); + + try { + $this->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->fail('Not restricted.'); + } catch (HintException $hintException) { + $this->assertEquals('You are not allowed to add this source.', $hintException->getHint()); + } + + $eventDispatcher->removeListener(RestrictInteractionEvent::class, $listener); + } + + public function testRemoveShareSource(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType2::class, 'source2')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType2::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + + $this->dbConnection->commit(); + + $before = $this->manager->generateTimestamp(); + $share = $this->removeShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals(ShareState::Active->value, $share['state']); + $this->assertEquals([ + [ + 'class' => TestShareSourceType2::class, + 'value' => 'source2', + 'display_name' => 'Source 2', + 'icon' => [ + 'svg' => '', + ], + ], + ], $share['sources']); + + $before = $this->manager->generateTimestamp(); + $share = $this->removeShareSource($accessContext, $id, new ShareSource(TestShareSourceType2::class, 'source2')); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals(ShareState::Draft->value, $share['state']); + $this->assertEquals([], $share['sources']); + } + + public function testAddShareRecipient(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->dbConnection->commit(); + + $before = $this->manager->generateTimestamp(); + $share = $this->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals([ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], $share['recipients']); + } + + public function testAddShareRecipientInteractionRestricted(): void { + $listener = function (RestrictInteractionEvent $event): void { + foreach ($event->receivers as $receiver) { + if ($receiver instanceof TestInteractionReceiver && $receiver->getID() === 'recipient1') { + throw new InteractionRestrictedException('Recipient not allowed.', 'You are not allowed to add this recipient.'); + } + } + }; + $eventDispatcher = Server::get(IEventDispatcher::class); + $eventDispatcher->addListener(RestrictInteractionEvent::class, $listener); + + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->dbConnection->commit(); + + try { + $this->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->fail('Interaction not restricted.'); + } catch (HintException $hintException) { + $this->assertEquals('You are not allowed to add this recipient.', $hintException->getHint()); + } + + $eventDispatcher->removeListener(RestrictInteractionEvent::class, $listener); + } + + public function testAddChildShareRecipientWithoutResharePermission(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + + $this->dbConnection->commit(); + + try { + $this->addShareRecipient(new ShareAccessContext($this->user1), $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $this->fail('Able to add child recipient without reshare permission.'); + } catch (HintException $hintException) { + $this->assertEquals('You are not allowed to edit this share.', $hintException->getHint()); + } + } + + public function testAddChildShareRecipientWithResharePermission(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(ReshareSharePermissionType::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + + $this->dbConnection->commit(); + + $before = $this->manager->generateTimestamp(); + $share = $this->addShareRecipient(new ShareAccessContext($this->user1), $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals([ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + [ + 'class' => TestShareRecipientType2::class, + 'value' => 'recipient2', + 'instance' => null, + 'display_name' => 'Recipient 2', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'user1', + 'instance' => null, + 'display_name' => 'User 1', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/user1/64', + 'dark' => 'http://localhost/index.php/avatar/user1/64/dark', + ], + ], + ], + ], $share['recipients']); + } + + public function testRemoveShareRecipient(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + + $this->dbConnection->commit(); + + $before = $this->manager->generateTimestamp(); + $share = $this->removeShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals(ShareState::Active->value, $share['state']); + $this->assertEquals([ + [ + 'class' => TestShareRecipientType2::class, + 'value' => 'recipient2', + 'instance' => null, + 'display_name' => 'Recipient 2', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], $share['recipients']); + + $before = $this->manager->generateTimestamp(); + $share = $this->removeShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals(ShareState::Draft->value, $share['state']); + $this->assertEquals([], $share['recipients']); + } + + public function testRemoveSelfShareRecipientWithoutResharePermission(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + + $this->dbConnection->commit(); + + try { + $this->removeShareRecipient(new ShareAccessContext($this->user1), $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->fail('Able to remove self recipient.'); + } catch (HintException $hintException) { + $this->assertEquals('You are not allowed to edit this share.', $hintException->getHint()); + } + } + + public function testRemoveSelfShareRecipientWithResharePermission(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(ReshareSharePermissionType::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + + $this->dbConnection->commit(); + + try { + $this->removeShareRecipient(new ShareAccessContext($this->user1), $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->fail('Able to remove self recipient.'); + } catch (HintException $hintException) { + $this->assertEquals('You are not allowed to edit this share.', $hintException->getHint()); + } + } + + public function testRemoveChildShareRecipientWithoutResharePermission(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(ReshareSharePermissionType::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + $this->manager->addShareRecipient(new ShareAccessContext($this->user1), $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(ReshareSharePermissionType::class, false)); + + $this->dbConnection->commit(); + + try { + $this->removeShareRecipient(new ShareAccessContext($this->user1), $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $this->fail('Able to remove child recipient without reshare permission.'); + } catch (HintException $hintException) { + $this->assertEquals('You are not allowed to edit this share.', $hintException->getHint()); + } + } + + public function testRemoveChildShareRecipientWithResharePermission(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(ReshareSharePermissionType::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + $this->manager->addShareRecipient(new ShareAccessContext($this->user1), $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + + $this->dbConnection->commit(); + + $before = $this->manager->generateTimestamp(); + $share = $this->removeShareRecipient(new ShareAccessContext($this->user1), $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals([ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], $share['recipients']); + } + + public function testRemoveSiblingShareRecipientWithoutResharePermission(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + + $this->dbConnection->commit(); + + try { + $this->removeShareRecipient(new ShareAccessContext($this->user1), $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $this->fail('Able to remove sibling recipient.'); + } catch (HintException $hintException) { + $this->assertEquals('You are not allowed to edit this share.', $hintException->getHint()); + } + } + + public function testRemoveSiblingShareRecipientWithResharePermission(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(ReshareSharePermissionType::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + + $this->dbConnection->commit(); + + try { + $this->removeShareRecipient(new ShareAccessContext($this->user1), $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $this->fail('Able to remove sibling recipient.'); + } catch (HintException $hintException) { + $this->assertEquals('You are not allowed to edit this share.', $hintException->getHint()); + } + } + + public function testRemoveParentShareRecipientWithoutResharePermission(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(ReshareSharePermissionType::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + $this->manager->addShareRecipient(new ShareAccessContext($this->user1), $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(ReshareSharePermissionType::class, false)); + + $this->dbConnection->commit(); + + try { + $this->removeShareRecipient(new ShareAccessContext($this->user2), $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->fail('Able to remove parent recipient.'); + } catch (HintException $hintException) { + $this->assertEquals('You are not allowed to edit this share.', $hintException->getHint()); + } + } + + public function testRemoveParentShareRecipientWithResharePermission(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(ReshareSharePermissionType::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + $this->manager->addShareRecipient(new ShareAccessContext($this->user1), $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + + $this->dbConnection->commit(); + + try { + $this->removeShareRecipient(new ShareAccessContext($this->user2), $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->fail('Able to remove parent recipient.'); + } catch (HintException $hintException) { + $this->assertEquals('You are not allowed to edit this share.', $hintException->getHint()); + } + } + + /** + * @return list + */ + public static function dataUpdateShareRecipientSecret(): array { + return [ + [true], + [false], + ]; + } + + #[DataProvider('dataUpdateShareRecipientSecret')] + public function testUpdateShareRecipientSecret(bool $isSecretUpdatable): void { + $this->registry->registerRecipientType(new TestShareRecipientTypePublicSecret( + [ + 'recipient1' => 'Recipient 1', + ], + [], + true, + $isSecretUpdatable, + )); + + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->getShare($accessContext, $id); + $recipient = new ShareRecipient(TestShareRecipientTypePublicSecret::class, 'recipient1', null); + $this->manager->addShareRecipient($accessContext, $id, $recipient); + $this->dbConnection->commit(); + + if (!$isSecretUpdatable) { + try { + $this->updateShareRecipientSecret($accessContext, $id, $recipient, 'mysecret'); + $this->fail('Able to update recipient secret.'); + } catch (HintException $exception) { + $this->assertEquals('You are not allowed to edit this share.', $exception->getHint()); + } + } else { + $before = $this->manager->generateTimestamp(); + $share = $this->updateShareRecipientSecret($accessContext, $id, $recipient, 'mysecret'); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + unset($share['last_updated']); + $this->assertEquals([ + [ + 'class' => TestShareRecipientTypePublicSecret::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '' + ], + 'secret' => [ + 'updatable' => true, + 'value' => 'mysecret', + 'url' => 'http://localhost/index.php/s/mysecret', + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], $share['recipients']); + } + } + + /** + * @return list}> + */ + public static function dataProviderUpdateShareProperty(): array { + return [ + [[null, 'valid1']], + [['valid1', null]], + ]; + } + + /** + * @param list $values + */ + #[DataProvider('dataProviderUpdateShareProperty')] + public function testUpdateShareProperty(array $values): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + + $this->dbConnection->commit(); + + foreach ($values as $value) { + $before = $this->manager->generateTimestamp(); + $share = $this->updateShareProperty($accessContext, $id, new ShareProperty(TestSharePropertyType1::class, $value)); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals([ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => $value, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + ], $share['properties']); + } + } + + public function testUpdateSharePropertyRequired(): void { + $this->registry->registerPropertyType(new TestSharePropertyTypeRequired(['valid1', 'valid2'])); + $this->registry->markPropertyTypeCompatibleWithSourceType(TestSharePropertyTypeRequired::class, TestShareSourceType1::class); + $this->registry->markPropertyTypeCompatibleWithRecipientType(TestSharePropertyTypeRequired::class, TestShareRecipientType1::class); + + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + + $this->dbConnection->commit(); + + $before = $this->manager->generateTimestamp(); + $share = $this->updateShareProperty($accessContext, $id, new ShareProperty(TestSharePropertyTypeRequired::class, 'valid1')); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals(ShareState::Draft->value, $share['state']); + $this->assertEquals([ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + [ + 'class' => TestSharePropertyTypeRequired::class, + 'display_name' => 'TestSharePropertyTypeRequired', + 'hint' => 'hint TestSharePropertyTypeRequired', + 'priority' => 1, + 'advanced' => false, + 'required' => true, + 'value' => 'valid1', + 'type' => 'enum', + 'valid_values' => ['valid1', 'valid2'], + ], + ], $share['properties']); + + $this->dbConnection->beginTransaction(); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + $this->dbConnection->commit(); + + $before = $this->manager->generateTimestamp(); + $share = $this->updateShareProperty($accessContext, $id, new ShareProperty(TestSharePropertyTypeRequired::class, 'valid2')); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals(ShareState::Active->value, $share['state']); + $this->assertEquals([ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + [ + 'class' => TestSharePropertyTypeRequired::class, + 'display_name' => 'TestSharePropertyTypeRequired', + 'hint' => 'hint TestSharePropertyTypeRequired', + 'priority' => 1, + 'advanced' => false, + 'required' => true, + 'value' => 'valid2', + 'type' => 'enum', + 'valid_values' => ['valid1', 'valid2'], + ], + ], $share['properties']); + + $before = $this->manager->generateTimestamp(); + $share = $this->updateShareProperty($accessContext, $id, new ShareProperty(TestSharePropertyTypeRequired::class, null)); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals(ShareState::Draft->value, $share['state']); + $this->assertEquals([ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + [ + 'class' => TestSharePropertyTypeRequired::class, + 'display_name' => 'TestSharePropertyTypeRequired', + 'hint' => 'hint TestSharePropertyTypeRequired', + 'priority' => 1, + 'advanced' => false, + 'required' => true, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1', 'valid2'], + ], + ], $share['properties']); + } + + public function testUpdateSharePropertyModifyProperties(): void { + $this->registry->registerPropertyType(new TestSharePropertyTypeModifyValue(['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-load'])); + $this->registry->markPropertyTypeCompatibleWithSourceType(TestSharePropertyTypeModifyValue::class, TestShareSourceType1::class); + $this->registry->markPropertyTypeCompatibleWithRecipientType(TestSharePropertyTypeModifyValue::class, TestShareRecipientType1::class); + + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateShareProperty($accessContext, $id, new ShareProperty(TestSharePropertyTypeModifyValue::class, 'old-value')); + + $this->dbConnection->commit(); + + $before = $this->manager->generateTimestamp(); + $share = $this->updateShareProperty($accessContext, $id, new ShareProperty(TestSharePropertyTypeModifyValue::class, 'modify-on-save-old-value')); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals([ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + [ + 'class' => TestSharePropertyTypeModifyValue::class, + 'display_name' => 'TestSharePropertyTypeModifyValue', + 'hint' => 'hint TestSharePropertyTypeModifyValue', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => 'old-value', + 'type' => 'enum', + 'valid_values' => ['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-load'], + ], + ], $share['properties']); + + $before = $this->manager->generateTimestamp(); + $share = $this->updateShareProperty($accessContext, $id, new ShareProperty(TestSharePropertyTypeModifyValue::class, 'modify-on-save')); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals([ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + [ + 'class' => TestSharePropertyTypeModifyValue::class, + 'display_name' => 'TestSharePropertyTypeModifyValue', + 'hint' => 'hint TestSharePropertyTypeModifyValue', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => 'modified-on-save', + 'type' => 'enum', + 'valid_values' => ['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-load'], + ], + ], $share['properties']); + + $before = $this->manager->generateTimestamp(); + $share = $this->updateShareProperty($accessContext, $id, new ShareProperty(TestSharePropertyTypeModifyValue::class, 'modify-on-load')); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals([ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + [ + 'class' => TestSharePropertyTypeModifyValue::class, + 'display_name' => 'TestSharePropertyTypeModifyValue', + 'hint' => 'hint TestSharePropertyTypeModifyValue', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => 'modified-on-load', + 'type' => 'enum', + 'valid_values' => ['old-value', 'modify-on-save-old-value', 'modify-on-save', 'modify-on-load'], + ], + ], $share['properties']); + } + + public function testUpdateSharePermission(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + + $this->dbConnection->commit(); + + $before = $this->manager->generateTimestamp(); + $this->updateSharePermission($accessContext, $id, new SharePermission(ReshareSharePermissionType::class, true)); + $share = $this->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals(ShareState::Draft->value, $share['state']); + $this->assertEquals([ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => true, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + ], $share['permissions']); + + $this->dbConnection->beginTransaction(); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + $this->dbConnection->commit(); + + $before = $this->manager->generateTimestamp(); + $share = $this->updateSharePermission($accessContext, $id, new SharePermission(ReshareSharePermissionType::class, false)); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals(ShareState::Active->value, $share['state']); + $this->assertEquals([ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + ], $share['permissions']); + + $before = $this->manager->generateTimestamp(); + $share = $this->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, false)); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals(ShareState::Draft->value, $share['state']); + $this->assertEquals([ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => false, + 'priority' => 1, + ], + ], $share['permissions']); + } + + public function testUpdateSharePermissionInteractionRestricted(): void { + $listener = function (RestrictInteractionEvent $event): void { + if ($event->action instanceof ShareAction && $event->action->unifiedSharingPermissions !== null && in_array(TestSharePermissionType1::class, $event->action->unifiedSharingPermissions, true)) { + throw new InteractionRestrictedException('Permission not allowed.', 'You are not allowed to enable this permission.'); + } + }; + $eventDispatcher = Server::get(IEventDispatcher::class); + $eventDispatcher->addListener(RestrictInteractionEvent::class, $listener); + + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + + $this->dbConnection->commit(); + + try { + $this->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + $this->fail('Not restricted.'); + } catch (HintException $hintException) { + $this->assertEquals('You are not allowed to enable this permission.', $hintException->getHint()); + } + + $eventDispatcher->removeListener(RestrictInteractionEvent::class, $listener); + } + + public function testSelectSharePermissionPreset(): void { + $this->registry->clear(); + $this->registry->registerSharingBackend(Server::get(SharingBackend::class)); + $this->registry->registerPermissionPreset(new TestSharePermissionPreset1()); + $this->registry->registerPermissionPreset(new TestSharePermissionPreset2()); + $this->registry->registerPermissionType(null, new TestSharePermissionType1()); + $this->registry->markPermissionTypeCompatibleWithPermissionPreset(TestSharePermissionType1::class, TestSharePermissionPreset1::class); + $this->registry->markPermissionTypeCompatibleWithPermissionPreset(TestSharePermissionType1::class, TestSharePermissionPreset2::class); + $this->registry->registerPermissionType(null, new TestSharePermissionType2()); + $this->registry->markPermissionTypeCompatibleWithPermissionPreset(TestSharePermissionType2::class, TestSharePermissionPreset2::class); + $this->registry->registerPermissionType(null, new TestSharePermissionType3()); + + $accessContext = new ShareAccessContext($this->owner); + + $before = $this->manager->generateTimestamp(); + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->getShare($accessContext, $id); + $this->dbConnection->commit(); + $after = $this->manager->generateTimestamp(); + + $share = $this->getShare($accessContext, $id); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertNull($share['permission_preset']); + $this->assertEquals([ + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => false, + 'priority' => 1, + ], + [ + 'class' => TestSharePermissionType2::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType2', + 'hint' => 'hint TestSharePermissionType2', + 'presets' => [TestSharePermissionPreset2::class], + 'enabled' => false, + 'priority' => 1, + ], + [ + 'class' => TestSharePermissionType3::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType3', + 'hint' => 'hint TestSharePermissionType3', + 'presets' => [], + 'enabled' => false, + 'priority' => 1, + ], + ], $share['permissions']); + + $before = $this->manager->generateTimestamp(); + $share = $this->selectSharePermissionPreset($accessContext, $id, TestSharePermissionPreset2::class); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals(TestSharePermissionPreset2::class, $share['permission_preset']); + $this->assertEquals([ + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + [ + 'class' => TestSharePermissionType2::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType2', + 'hint' => 'hint TestSharePermissionType2', + 'presets' => [TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + [ + 'class' => TestSharePermissionType3::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType3', + 'hint' => 'hint TestSharePermissionType3', + 'presets' => [], + 'enabled' => false, + 'priority' => 1, + ], + ], $share['permissions']); + + $before = $this->manager->generateTimestamp(); + $share = $this->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType3::class, true)); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertNull($share['permission_preset']); + $this->assertEquals([ + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + [ + 'class' => TestSharePermissionType2::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType2', + 'hint' => 'hint TestSharePermissionType2', + 'presets' => [TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + [ + 'class' => TestSharePermissionType3::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType3', + 'hint' => 'hint TestSharePermissionType3', + 'presets' => [], + 'enabled' => true, + 'priority' => 1, + ], + ], $share['permissions']); + + $before = $this->manager->generateTimestamp(); + $share = $this->selectSharePermissionPreset($accessContext, $id, TestSharePermissionPreset1::class); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals(TestSharePermissionPreset1::class, $share['permission_preset']); + $this->assertEquals([ + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + [ + 'class' => TestSharePermissionType2::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType2', + 'hint' => 'hint TestSharePermissionType2', + 'presets' => [TestSharePermissionPreset2::class], + 'enabled' => false, + 'priority' => 1, + ], + [ + 'class' => TestSharePermissionType3::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType3', + 'hint' => 'hint TestSharePermissionType3', + 'presets' => [], + 'enabled' => false, + 'priority' => 1, + ], + ], $share['permissions']); + + $before = $this->manager->generateTimestamp(); + $share = $this->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, false)); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertNull($share['permission_preset']); + $this->assertEquals([ + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => false, + 'priority' => 1, + ], + [ + 'class' => TestSharePermissionType2::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType2', + 'hint' => 'hint TestSharePermissionType2', + 'presets' => [TestSharePermissionPreset2::class], + 'enabled' => false, + 'priority' => 1, + ], + [ + 'class' => TestSharePermissionType3::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType3', + 'hint' => 'hint TestSharePermissionType3', + 'presets' => [], + 'enabled' => false, + 'priority' => 1, + ], + ], $share['permissions']); + } + + public function testSelectSharePermissionPresetCompatible(): void { + $this->registry->clear(); + $this->registry->registerSharingBackend(Server::get(SharingBackend::class)); + $this->registry->registerSourceType(new TestShareSourceType1(['source1' => 'Source 1'])); + $this->registry->registerPermissionPreset(new TestSharePermissionPreset1()); + $this->registry->registerPermissionPreset(new TestSharePermissionPreset2()); + $this->registry->registerPermissionType(TestShareSourceType1::class, new TestSharePermissionType1()); + $this->registry->markPermissionTypeCompatibleWithPermissionPreset(TestSharePermissionType1::class, TestSharePermissionPreset1::class); + $this->registry->markPermissionTypeCompatibleWithPermissionPreset(TestSharePermissionType1::class, TestSharePermissionPreset2::class); + $this->registry->registerPermissionType(null, new TestSharePermissionType2()); + $this->registry->markPermissionTypeCompatibleWithPermissionPreset(TestSharePermissionType2::class, TestSharePermissionPreset2::class); + + $accessContext = new ShareAccessContext($this->owner); + + $before = $this->manager->generateTimestamp(); + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->getShare($accessContext, $id); + $this->dbConnection->commit(); + $after = $this->manager->generateTimestamp(); + + $share = $this->getShare($accessContext, $id); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertNull($share['permission_preset']); + $this->assertEquals([ + [ + 'class' => TestSharePermissionType2::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType2', + 'hint' => 'hint TestSharePermissionType2', + 'presets' => [TestSharePermissionPreset2::class], + 'enabled' => false, + 'priority' => 1, + ], + ], $share['permissions']); + + $before = $this->manager->generateTimestamp(); + $share = $this->selectSharePermissionPreset($accessContext, $id, TestSharePermissionPreset2::class); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals(TestSharePermissionPreset2::class, $share['permission_preset']); + $this->assertEquals([ + [ + 'class' => TestSharePermissionType2::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType2', + 'hint' => 'hint TestSharePermissionType2', + 'presets' => [TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + ], $share['permissions']); + + $before = $this->manager->generateTimestamp(); + $share = $this->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertNull($share['permission_preset']); + $this->assertEquals([ + [ + 'class' => TestSharePermissionType2::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType2', + 'hint' => 'hint TestSharePermissionType2', + 'presets' => [TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => false, + 'priority' => 1, + ], + ], $share['permissions']); + + $before = $this->manager->generateTimestamp(); + $share = $this->selectSharePermissionPreset($accessContext, $id, TestSharePermissionPreset2::class); + $after = $this->manager->generateTimestamp(); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals(TestSharePermissionPreset2::class, $share['permission_preset']); + $this->assertEquals([ + [ + 'class' => TestSharePermissionType2::class, + 'source_class' => null, + 'display_name' => 'TestSharePermissionType2', + 'hint' => 'hint TestSharePermissionType2', + 'presets' => [TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + ], $share['permissions']); + } + + public function testDeleteShare(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + + $this->deleteShare($accessContext, $id); + + try { + $this->manager->getShare(new ShareAccessContext(overrideChecks: true), $id); + $this->fail('Share not deleted.'); + } catch (HintException $hintException) { + $this->assertEquals('Share not found.', $hintException->getHint()); + } finally { + $this->dbConnection->commit(); + } + } + + public function testGetShare(): void { + $accessContext = new ShareAccessContext($this->owner); + + $before = $this->manager->generateTimestamp(); + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + + $this->dbConnection->commit(); + $after = $this->manager->generateTimestamp(); + + $share = $this->getShare($accessContext, $id); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + unset($share['last_updated']); + $this->assertEquals([ + 'id' => $id, + 'owner' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + 'state' => ShareState::Draft->value, + 'sources' => [ + [ + 'class' => TestShareSourceType1::class, + 'value' => 'source1', + 'display_name' => 'Source 1', + 'icon' => [ + 'svg' => '', + ], + ], + ], + 'recipients' => [ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], + 'properties' => [ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + ], + 'permissions' => [ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => false, + 'priority' => 1, + ], + ], + 'permission_preset' => null, + ], $share); + } + + public function testGetShareAsRecipientNotActive(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + + $this->dbConnection->commit(); + + try { + $this->getShare(new ShareAccessContext(currentUser: $this->user1), $id); + $this->fail('Draft share visible.'); + } catch (HintException $hintException) { + $this->assertEquals('Share not found.', $hintException->getHint()); + } + } + + public function testGetShareAsRecipientActive(): void { + $accessContext = new ShareAccessContext($this->owner); + + $before = $this->manager->generateTimestamp(); + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + + $this->dbConnection->commit(); + $after = $this->manager->generateTimestamp(); + + $share = $this->getShare(new ShareAccessContext(currentUser: $this->user1), $id); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + unset($share['last_updated']); + $this->assertEquals([ + 'id' => $id, + 'owner' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + 'state' => ShareState::Active->value, + 'sources' => [ + [ + 'class' => TestShareSourceType1::class, + 'value' => 'source1', + 'display_name' => 'Source 1', + 'icon' => [ + 'svg' => '', + ], + ], + ], + 'recipients' => [ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + [ + 'class' => TestShareRecipientType2::class, + 'value' => 'recipient2', + 'instance' => null, + 'display_name' => 'Recipient 2', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], + 'properties' => [ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + ], + 'permissions' => [ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + ], + 'permission_preset' => TestSharePermissionPreset1::class, + ], $share); + } + + public function testGetShareAsRecipientWithArguments(): void { + $this->registry->registerRecipientType(new TestShareRecipientTypeArguments()); + + $accessContext = new ShareAccessContext($this->owner); + + $before = $this->manager->generateTimestamp(); + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientTypeArguments::class, 'secret', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + + $this->dbConnection->commit(); + $after = $this->manager->generateTimestamp(); + + $share = $this->getShare(new ShareAccessContext(currentUser: $this->user1, arguments: [TestShareRecipientTypeArguments::class => 'secret']), $id); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + unset($share['last_updated']); + $this->assertEquals([ + 'id' => $id, + 'owner' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + 'state' => ShareState::Active->value, + 'sources' => [ + [ + 'class' => TestShareSourceType1::class, + 'value' => 'source1', + 'display_name' => 'Source 1', + 'icon' => [ + 'svg' => '', + ], + ], + ], + 'recipients' => [ + [ + 'class' => TestShareRecipientTypeArguments::class, + 'value' => 'secret', + 'instance' => null, + 'display_name' => 'secret', + 'icon' => null, + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], + 'properties' => [], + 'permissions' => [ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + ], + 'permission_preset' => TestSharePermissionPreset1::class, + ], $share); + + try { + $this->getShare(new ShareAccessContext(currentUser: $this->user1), $id); + $this->fail('Share visible without arguments.'); + } catch (HintException $hintException) { + $this->assertEquals('Share not found.', $hintException->getHint()); + } + } + + public function testGetShareWithSecretNotActive(): void { + $this->registry->registerRecipientType(new TestShareRecipientTypeArguments()); + + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientTypeArguments::class, 'secret', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + + $share = $this->manager->getShare($accessContext, $id); + $this->dbConnection->commit(); + $secret = $share->recipients[0]->secret; + $this->assertNotNull($secret); + + try { + $this->getShare(new ShareAccessContext(secret: $secret), $id); + $this->fail('Draft share visible with secret.'); + } catch (HintException $hintException) { + $this->assertEquals('Share not found.', $hintException->getHint()); + } + } + + public function testGetShareWithSecretActive(): void { + $this->registry->registerRecipientType(new TestShareRecipientTypeArguments()); + + $accessContext = new ShareAccessContext($this->owner); + + $before = $this->manager->generateTimestamp(); + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientTypeArguments::class, 'secret', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + + $after = $this->manager->generateTimestamp(); + + $share = $this->manager->getShare($accessContext, $id); + $this->dbConnection->commit(); + $secret = $share->recipients[0]->secret; + $this->assertNotNull($secret); + + $share = $this->getShare(new ShareAccessContext(secret: $secret), $id); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + unset($share['last_updated']); + $this->assertEquals([ + 'id' => $id, + 'owner' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + 'state' => ShareState::Active->value, + 'sources' => [ + [ + 'class' => TestShareSourceType1::class, + 'value' => 'source1', + 'display_name' => 'Source 1', + 'icon' => [ + 'svg' => '', + ], + ], + ], + 'recipients' => [ + [ + 'class' => TestShareRecipientTypeArguments::class, + 'value' => 'secret', + 'instance' => null, + 'display_name' => 'secret', + 'icon' => null, + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], + 'properties' => [], + 'permissions' => [ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + ], + 'permission_preset' => TestSharePermissionPreset1::class, + ], $share); + + try { + $this->getShare(new ShareAccessContext(), $id); + $this->fail('Share visible without secret.'); + } catch (HintException $hintException) { + $this->assertEquals('Share not found.', $hintException->getHint()); + } + } + + public function testGetShareAsNonRecipient(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + + $this->dbConnection->commit(); + + try { + $this->getShare(new ShareAccessContext(currentUser: $this->user1), $id); + $this->fail('Share visible as non-recipient.'); + } catch (HintException $hintException) { + $this->assertEquals('Share not found.', $hintException->getHint()); + } + } + + public function testGetShareAsRecipientFilteredProperties(): void { + $this->registry->registerPropertyType(new TestSharePropertyTypeFilter(['visible', 'filtered'])); + $this->registry->markPropertyTypeCompatibleWithSourceType(TestSharePropertyTypeFilter::class, TestShareSourceType1::class); + $this->registry->markPropertyTypeCompatibleWithRecipientType(TestSharePropertyTypeFilter::class, TestShareRecipientType1::class); + + $accessContext = new ShareAccessContext($this->owner); + + $before = $this->manager->generateTimestamp(); + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + $this->manager->updateShareProperty($accessContext, $id, new ShareProperty(TestSharePropertyTypeFilter::class, 'visible')); + + $this->dbConnection->commit(); + $after = $this->manager->generateTimestamp(); + + $share = $this->getShare(new ShareAccessContext(currentUser: $this->user1), $id); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + unset($share['last_updated']); + $this->assertEquals([ + 'id' => $id, + 'owner' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + 'state' => ShareState::Active->value, + 'sources' => [ + [ + 'class' => TestShareSourceType1::class, + 'value' => 'source1', + 'display_name' => 'Source 1', + 'icon' => [ + 'svg' => '', + ], + ], + ], + 'recipients' => [ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], + 'properties' => [ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + [ + 'class' => TestSharePropertyTypeFilter::class, + 'display_name' => 'TestSharePropertyTypeFilter', + 'hint' => 'hint TestSharePropertyTypeFilter', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => 'visible', + 'type' => 'enum', + 'valid_values' => ['visible', 'filtered'], + ], + ], + 'permissions' => [ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + ], + 'permission_preset' => TestSharePermissionPreset1::class, + ], $share); + + $before = $this->manager->generateTimestamp(); + $this->dbConnection->beginTransaction(); + $this->manager->updateShareProperty($accessContext, $id, new ShareProperty(TestSharePropertyTypeFilter::class, 'filtered')); + $this->dbConnection->commit(); + $after = $this->manager->generateTimestamp(); + + $share = $this->getShare(new ShareAccessContext(currentUser: $this->owner), $id); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + unset($share['last_updated']); + $this->assertEquals([ + 'id' => $id, + 'owner' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + 'state' => ShareState::Active->value, + 'sources' => [ + [ + 'class' => TestShareSourceType1::class, + 'value' => 'source1', + 'display_name' => 'Source 1', + 'icon' => [ + 'svg' => '', + ], + ], + ], + 'recipients' => [ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], + 'properties' => [ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + [ + 'class' => TestSharePropertyTypeFilter::class, + 'display_name' => 'TestSharePropertyTypeFilter', + 'hint' => 'hint TestSharePropertyTypeFilter', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => 'filtered', + 'type' => 'enum', + 'valid_values' => ['visible', 'filtered'], + ], + ], + 'permissions' => [ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + ], + 'permission_preset' => TestSharePermissionPreset1::class, + ], $share); + + try { + $this->getShare(new ShareAccessContext(currentUser: $this->user1), $id); + $this->fail('Share visible with active filter property.'); + } catch (HintException $hintException) { + $this->assertEquals('Share not found.', $hintException->getHint()); + } + } + + public function testGetShareAsRecipientFilteredArguments(): void { + $this->registry->registerPropertyType(new TestSharePropertyTypeFilter(['visible', 'filtered'])); + $this->registry->markPropertyTypeCompatibleWithSourceType(TestSharePropertyTypeFilter::class, TestShareSourceType1::class); + $this->registry->markPropertyTypeCompatibleWithRecipientType(TestSharePropertyTypeFilter::class, TestShareRecipientType1::class); + + $accessContext = new ShareAccessContext($this->owner); + + $before = $this->manager->generateTimestamp(); + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + + $this->dbConnection->commit(); + $after = $this->manager->generateTimestamp(); + + $share = $this->getShare(new ShareAccessContext(currentUser: $this->user1), $id); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + unset($share['last_updated']); + $this->assertEquals([ + 'id' => $id, + 'owner' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + 'state' => ShareState::Active->value, + 'sources' => [ + [ + 'class' => TestShareSourceType1::class, + 'value' => 'source1', + 'display_name' => 'Source 1', + 'icon' => [ + 'svg' => '', + ], + ], + ], + 'recipients' => [ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], + 'properties' => [ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + [ + 'class' => TestSharePropertyTypeFilter::class, + 'display_name' => 'TestSharePropertyTypeFilter', + 'hint' => 'hint TestSharePropertyTypeFilter', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['visible', 'filtered'], + ], + ], + 'permissions' => [ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + ], + 'permission_preset' => TestSharePermissionPreset1::class, + ], $share); + + $share = $this->getShare(new ShareAccessContext(currentUser: $this->owner, arguments: [TestSharePropertyTypeFilter::class => 'filtered']), $id); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + unset($share['last_updated']); + $this->assertEquals([ + 'id' => $id, + 'owner' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + 'state' => ShareState::Active->value, + 'sources' => [ + [ + 'class' => TestShareSourceType1::class, + 'value' => 'source1', + 'display_name' => 'Source 1', + 'icon' => [ + 'svg' => '', + ], + ], + ], + 'recipients' => [ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], + 'properties' => [ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + [ + 'class' => TestSharePropertyTypeFilter::class, + 'display_name' => 'TestSharePropertyTypeFilter', + 'hint' => 'hint TestSharePropertyTypeFilter', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['visible', 'filtered'], + ], + ], + 'permissions' => [ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => true, + 'priority' => 1, + ], + ], + 'permission_preset' => TestSharePermissionPreset1::class, + ], $share); + + try { + $this->getShare(new ShareAccessContext(currentUser: $this->user1, arguments: [TestSharePropertyTypeFilter::class => 'filtered']), $id); + $this->fail('Share visible with filtered value as recipient.'); + } catch (HintException $hintException) { + $this->assertEquals('Share not found.', $hintException->getHint()); + } + } + + /** + * @return list + */ + public static function dataGetShareWithPublicSecret(): array { + return [ + [true], + [false], + ]; + } + + #[DataProvider('dataGetShareWithPublicSecret')] + public function testGetShareWithPublicSecret(bool $isSecretPublic): void { + $this->registry->clear(); + $this->registry->registerSharingBackend(Server::get(SharingBackend::class)); + $this->registry->registerRecipientType(new TestShareRecipientType1( + [ + 'recipient1' => 'Recipient 1', + ], + [], + [], + )); + $this->registry->registerRecipientType(new TestShareRecipientTypePublicSecret( + [ + 'recipient2' => 'Recipient 2', + ], + [], + $isSecretPublic, + false, + )); + + $accessContext = new ShareAccessContext($this->owner); + + $before = $this->manager->generateTimestamp(); + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientTypePublicSecret::class, 'recipient2', null)); + + $this->dbConnection->commit(); + $after = $this->manager->generateTimestamp(); + + $share = $this->getShare($accessContext, $id); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + unset($share['last_updated']); + $this->assertIsList($share['recipients']); + $this->assertCount(2, $share['recipients']); + $this->assertEquals([ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], $share['recipients'][0]); + $this->assertIsArray($share['recipients'][1]); + if ($isSecretPublic) { + $this->assertArrayHasKey('secret', $share['recipients'][1]); + $this->assertIsArray($share['recipients'][1]['secret']); + + $this->assertArrayHasKey('updatable', $share['recipients'][1]['secret']); + $this->assertFalse($share['recipients'][1]['secret']['updatable']); + + $this->assertArrayHasKey('value', $share['recipients'][1]['secret']); + $this->assertIsString($share['recipients'][1]['secret']['value']); + $this->assertNotEmpty($share['recipients'][1]['secret']['value']); + + $this->assertArrayHasKey('url', $share['recipients'][1]['secret']); + $this->assertIsString($share['recipients'][1]['secret']['url']); + $this->assertMatchesRegularExpression('/http:\/\/localhost\/index\.php\/s\/.+/', $share['recipients'][1]['secret']['url']); + } else { + $this->assertArrayNotHasKey('url', $share['recipients'][1]); + } + } + + public function testGetShareWithSecret(): void { + $this->registry->clear(); + $this->registry->registerSharingBackend(Server::get(SharingBackend::class)); + $this->registry->registerSourceType(new TestShareSourceType1(['source1' => 'Source'])); + $this->registry->registerRecipientType(new TestShareRecipientTypePublicSecret( + [ + 'recipient1' => 'Recipient 1', + 'recipient2' => 'Recipient 2', + 'recipient3' => 'Recipient 3', + 'recipient4' => 'Recipient 4', + ], + [ + $this->user1->getUID() => ['recipient1'], + $this->user2->getUID() => ['recipient2'], + ], + true, + false, + )); + $this->registry->registerPermissionType(null, new ReshareSharePermissionType()); + + $accessContext = new ShareAccessContext($this->owner); + + $before = $this->manager->generateTimestamp(); + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientTypePublicSecret::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(ReshareSharePermissionType::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + $this->manager->addShareRecipient(new ShareAccessContext($this->user1), $id, new ShareRecipient(TestShareRecipientTypePublicSecret::class, 'recipient2', null)); + $this->manager->addShareRecipient(new ShareAccessContext($this->user1), $id, new ShareRecipient(TestShareRecipientTypePublicSecret::class, 'recipient3', null)); + $this->manager->addShareRecipient(new ShareAccessContext($this->user2), $id, new ShareRecipient(TestShareRecipientTypePublicSecret::class, 'recipient4', null)); + + $this->dbConnection->commit(); + $after = $this->manager->generateTimestamp(); + + $share = $this->getShare(new ShareAccessContext($this->user2), $id); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + + $this->assertArrayHasKey('recipients', $share); + $this->assertIsArray($share['recipients']); + $this->assertCount(4, $share['recipients']); + + // Parent - secret not visible + $this->assertIsArray($share['recipients'][0]); + $this->assertArrayHasKey('value', $share['recipients'][0]); + $this->assertEquals('recipient1', $share['recipients'][0]['value']); + $this->assertArrayHasKey('secret', $share['recipients'][0]); + $this->assertIsArray($share['recipients'][0]['secret']); + $this->assertArrayNotHasKey('value', $share['recipients'][0]['secret']); + + // Self - secret visible + $this->assertIsArray($share['recipients'][1]); + $this->assertArrayHasKey('value', $share['recipients'][1]); + $this->assertEquals('recipient2', $share['recipients'][1]['value']); + $this->assertArrayHasKey('secret', $share['recipients'][1]); + $this->assertIsArray($share['recipients'][1]['secret']); + $this->assertNotEmpty($share['recipients'][1]['secret']['value']); + + // Sibling - secret not visible + $this->assertIsArray($share['recipients'][2]); + $this->assertArrayHasKey('value', $share['recipients'][2]); + $this->assertEquals('recipient3', $share['recipients'][2]['value']); + $this->assertArrayHasKey('secret', $share['recipients'][2]); + $this->assertIsArray($share['recipients'][2]['secret']); + $this->assertArrayNotHasKey('value', $share['recipients'][2]['secret']); + + // Child - secret visible + $this->assertIsArray($share['recipients'][3]); + $this->assertArrayHasKey('value', $share['recipients'][3]); + $this->assertEquals('recipient4', $share['recipients'][3]['value']); + $this->assertArrayHasKey('secret', $share['recipients'][3]); + $this->assertIsArray($share['recipients'][3]['secret']); + $this->assertNotEmpty($share['recipients'][3]['secret']['value']); + } + + public function testGetShareUniqueDisplayNames(): void { + $this->registry->clear(); + $this->registry->registerSharingBackend(Server::get(SharingBackend::class)); + $this->registry->registerSourceType(new TestShareSourceType1(['source1' => 'Source'])); + $this->registry->registerSourceType(new TestShareSourceType2(['source2' => 'Source', 'source3' => 'Other'])); + $this->registry->registerRecipientType(new TestShareRecipientType1(['recipient1' => 'Recipient'], [], [])); + $this->registry->registerRecipientType(new TestShareRecipientType2(['recipient2' => 'Recipient', 'recipient3' => 'Other'], [], [])); + + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType2::class, 'source2')); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType2::class, 'source3')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient3', null)); + + $this->dbConnection->commit(); + + $share = $this->getShare($accessContext, $id); + $this->assertEquals([ + [ + 'class' => TestShareSourceType1::class, + 'value' => 'source1', + 'display_name' => 'Source (TestShareSourceType1: source1)', + 'icon' => [ + 'svg' => '', + ], + ], + [ + 'class' => TestShareSourceType2::class, + 'value' => 'source2', + 'display_name' => 'Source (TestShareSourceType2: source2)', + 'icon' => [ + 'svg' => '', + ], + ], + [ + 'class' => TestShareSourceType2::class, + 'value' => 'source3', + 'display_name' => 'Other', + 'icon' => [ + 'svg' => '', + ], + ], + ], $share['sources']); + $this->assertEquals([ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient (TestShareRecipientType1: recipient1)', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + [ + 'class' => TestShareRecipientType2::class, + 'value' => 'recipient2', + 'instance' => null, + 'display_name' => 'Recipient (TestShareRecipientType2: recipient2)', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + [ + 'class' => TestShareRecipientType2::class, + 'value' => 'recipient3', + 'instance' => null, + 'display_name' => 'Other', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], $share['recipients']); + } + + public function testGetShareDisabledOwner(): void { + $accessContext = new ShareAccessContext($this->owner); + + $before = $this->manager->generateTimestamp(); + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $this->manager->getShare($accessContext, $id); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(TestSharePermissionType1::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + + $this->dbConnection->commit(); + $after = $this->manager->generateTimestamp(); + + $this->owner->setEnabled(false); + + try { + $this->getShare(new ShareAccessContext(currentUser: $this->user1), $id); + $this->fail('Share still visible.'); + } catch (HintException $hintException) { + $this->assertEquals('Share not found.', $hintException->getHint()); + } + + $share = $this->getShare(new ShareAccessContext(overrideChecks: true), $id); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals([ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], $share['owner']); + } + + public function testGetShareDisabledInitiator(): void { + $accessContext = new ShareAccessContext($this->owner); + + $before = $this->manager->generateTimestamp(); + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(ReshareSharePermissionType::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + $this->manager->addShareRecipient(new ShareAccessContext(currentUser: $this->user1), $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + + $this->dbConnection->commit(); + $after = $this->manager->generateTimestamp(); + + $this->user1->setEnabled(false); + + try { + $this->getShare(new ShareAccessContext(currentUser: $this->user2), $id); + $this->fail('Share still visible.'); + } catch (HintException $hintException) { + $this->assertEquals('Share not found.', $hintException->getHint()); + } + + $share = $this->getShare($accessContext, $id); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals([ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + [ + 'class' => TestShareRecipientType2::class, + 'value' => 'recipient2', + 'instance' => null, + 'display_name' => 'Recipient 2', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'user1', + 'instance' => null, + 'display_name' => 'User 1', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/user1/64', + 'dark' => 'http://localhost/index.php/avatar/user1/64/dark', + ], + ], + ], + ], $share['recipients']); + } + + public function testGetShares(): void { + $accessContext = new ShareAccessContext($this->owner); + + $before1 = $this->manager->generateTimestamp(); + $this->dbConnection->beginTransaction(); + $id1 = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id1, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id1, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->getShare($accessContext, $id1); + + $this->dbConnection->commit(); + $after1 = $this->manager->generateTimestamp(); + + $before2 = $this->manager->generateTimestamp(); + $this->dbConnection->beginTransaction(); + $id2 = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id2, new ShareSource(TestShareSourceType2::class, 'source2')); + $this->manager->addShareRecipient($accessContext, $id2, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + $this->manager->getShare($accessContext, $id2); + + $this->dbConnection->commit(); + $after2 = $this->manager->generateTimestamp(); + + $shares = $this->getShares($accessContext, null, null, null, null); + $this->assertCount(2, $shares); + $this->assertIsArray($shares[0]); + $this->assertGreaterThanOrEqual($before1, $shares[0]['last_updated']); + $this->assertLessThanOrEqual($after1, $shares[0]['last_updated']); + $this->assertIsArray($shares[1]); + $this->assertGreaterThanOrEqual($before2, $shares[1]['last_updated']); + $this->assertLessThanOrEqual($after2, $shares[1]['last_updated']); + unset($shares[0]['last_updated'], $shares[1]['last_updated']); + $this->assertEquals([ + [ + 'id' => $id1, + 'owner' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + 'state' => ShareState::Draft->value, + 'sources' => [ + [ + 'class' => TestShareSourceType1::class, + 'value' => 'source1', + 'display_name' => 'Source 1', + 'icon' => [ + 'svg' => '', + ], + ], + ], + 'recipients' => [ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], + 'properties' => [ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + ], + 'permissions' => [ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => false, + 'priority' => 1, + ], + ], + 'permission_preset' => null, + ], + [ + 'id' => $id2, + 'owner' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + 'state' => ShareState::Draft->value, + 'sources' => [ + [ + 'class' => TestShareSourceType2::class, + 'value' => 'source2', + 'display_name' => 'Source 2', + 'icon' => [ + 'svg' => '', + ], + ], + ], + 'recipients' => [ + [ + 'class' => TestShareRecipientType2::class, + 'value' => 'recipient2', + 'instance' => null, + 'display_name' => 'Recipient 2', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], + 'properties' => [ + [ + 'class' => TestSharePropertyType2::class, + 'display_name' => 'TestSharePropertyType2', + 'hint' => 'hint TestSharePropertyType2', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid2'], + ], + ], + 'permissions' => [ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType2::class, + 'source_class' => TestShareSourceType2::class, + 'display_name' => 'TestSharePermissionType2', + 'hint' => 'hint TestSharePermissionType2', + 'presets' => [TestSharePermissionPreset2::class], + 'enabled' => false, + 'priority' => 1, + ], + ], + 'permission_preset' => null, + ], + ], $shares); + + $shares = $this->getShares($accessContext, TestShareSourceType1::class, null, null, null); + $this->assertCount(1, $shares); + $this->assertIsArray($shares[0]); + $this->assertGreaterThanOrEqual($before1, $shares[0]['last_updated']); + $this->assertLessThanOrEqual($after1, $shares[0]['last_updated']); + unset($shares[0]['last_updated']); + $this->assertEquals([ + [ + 'id' => $id1, + 'owner' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + 'state' => ShareState::Draft->value, + 'sources' => [ + [ + 'class' => TestShareSourceType1::class, + 'value' => 'source1', + 'display_name' => 'Source 1', + 'icon' => [ + 'svg' => '', + ], + ], + ], + 'recipients' => [ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], + 'properties' => [ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + ], + 'permissions' => [ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => false, + 'priority' => 1, + ], + ], + 'permission_preset' => null, + ], + ], $shares); + + $shares = $this->getShares($accessContext, TestShareSourceType1::class, 'source1', null, null); + $this->assertCount(1, $shares); + $this->assertIsArray($shares[0]); + $this->assertGreaterThanOrEqual($before1, $shares[0]['last_updated']); + $this->assertLessThanOrEqual($after1, $shares[0]['last_updated']); + unset($shares[0]['last_updated']); + $this->assertEquals([ + [ + 'id' => $id1, + 'owner' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + 'state' => ShareState::Draft->value, + 'sources' => [ + [ + 'class' => TestShareSourceType1::class, + 'value' => 'source1', + 'display_name' => 'Source 1', + 'icon' => [ + 'svg' => '', + ], + ], + ], + 'recipients' => [ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], + 'properties' => [ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + ], + 'permissions' => [ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => false, + 'priority' => 1, + ], + ], + 'permission_preset' => null, + ], + ], $shares); + + $shares = $this->getShares($accessContext, TestShareSourceType1::class, 'non-existent', null, null); + $this->assertCount(0, $shares); + + $shares = $this->getShares($accessContext, null, null, $id1, null); + $this->assertCount(1, $shares); + $this->assertIsArray($shares[0]); + $this->assertGreaterThanOrEqual($before2, $shares[0]['last_updated']); + $this->assertLessThanOrEqual($after2, $shares[0]['last_updated']); + unset($shares[0]['last_updated']); + $this->assertEquals([ + [ + 'id' => $id2, + 'owner' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + 'state' => ShareState::Draft->value, + 'sources' => [ + [ + 'class' => TestShareSourceType2::class, + 'value' => 'source2', + 'display_name' => 'Source 2', + 'icon' => [ + 'svg' => '', + ], + ], + ], + 'recipients' => [ + [ + 'class' => TestShareRecipientType2::class, + 'value' => 'recipient2', + 'instance' => null, + 'display_name' => 'Recipient 2', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], + 'properties' => [ + [ + 'class' => TestSharePropertyType2::class, + 'display_name' => 'TestSharePropertyType2', + 'hint' => 'hint TestSharePropertyType2', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid2'], + ], + ], + 'permissions' => [ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType2::class, + 'source_class' => TestShareSourceType2::class, + 'display_name' => 'TestSharePermissionType2', + 'hint' => 'hint TestSharePermissionType2', + 'presets' => [TestSharePermissionPreset2::class], + 'enabled' => false, + 'priority' => 1, + ], + ], + 'permission_preset' => null, + ], + ], $shares); + + $shares = $this->getShares($accessContext, null, null, null, 1); + $this->assertCount(1, $shares); + $this->assertIsArray($shares[0]); + $this->assertGreaterThanOrEqual($before1, $shares[0]['last_updated']); + $this->assertLessThanOrEqual($after1, $shares[0]['last_updated']); + unset($shares[0]['last_updated']); + $this->assertEquals([ + [ + 'id' => $id1, + 'owner' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + 'state' => ShareState::Draft->value, + 'sources' => [ + [ + 'class' => TestShareSourceType1::class, + 'value' => 'source1', + 'display_name' => 'Source 1', + 'icon' => [ + 'svg' => '', + ], + ], + ], + 'recipients' => [ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], + 'properties' => [ + [ + 'class' => TestSharePropertyType1::class, + 'display_name' => 'TestSharePropertyType1', + 'hint' => 'hint TestSharePropertyType1', + 'priority' => 1, + 'advanced' => false, + 'required' => false, + 'value' => null, + 'type' => 'enum', + 'valid_values' => ['valid1'], + ], + ], + 'permissions' => [ + [ + 'class' => ReshareSharePermissionType::class, + 'source_class' => null, + 'display_name' => 'Share with others', + 'hint' => null, + 'presets' => [], + 'enabled' => false, + 'priority' => 90, + ], + [ + 'class' => TestSharePermissionType1::class, + 'source_class' => TestShareSourceType1::class, + 'display_name' => 'TestSharePermissionType1', + 'hint' => 'hint TestSharePermissionType1', + 'presets' => [TestSharePermissionPreset1::class, TestSharePermissionPreset2::class], + 'enabled' => false, + 'priority' => 1, + ], + ], + 'permission_preset' => null, + ], + ], $shares); + } + + public function testGetSharesSorted(): void { + $accessContext = new ShareAccessContext(currentUser: $this->owner); + + $this->dbConnection->beginTransaction(); + $id1 = $this->manager->createShare($accessContext); + $id2 = $this->manager->createShare($accessContext); + $this->dbConnection->commit(); + + $shares = $this->getShares($accessContext, null, null, null, null); + $this->assertIsArray($shares[0]); + $this->assertArrayHasKey('id', $shares[0]); + $this->assertIsArray($shares[1]); + $this->assertArrayHasKey('id', $shares[1]); + $this->assertEquals($id1, $shares[0]['id']); + $this->assertEquals($id2, $shares[1]['id']); + + $this->dbConnection->beginTransaction(); + $this->manager->addShareSource($accessContext, $id2, new ShareSource(TestShareSourceType2::class, 'source2')); + $this->manager->getShare($accessContext, $id2); + $this->manager->updateSharePermission($accessContext, $id2, new SharePermission(TestSharePermissionType2::class, true)); + + $this->dbConnection->commit(); + + $shares = $this->getShares($accessContext, null, null, null, null); + $this->assertIsArray($shares[0]); + $this->assertArrayHasKey('id', $shares[0]); + $this->assertIsArray($shares[1]); + $this->assertArrayHasKey('id', $shares[1]); + $this->assertEquals($id2, $shares[0]['id']); + $this->assertEquals($id1, $shares[1]['id']); + } + + public function testOwnerDeleted(): void { + $accessContext = new ShareAccessContext(currentUser: $this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->owner->delete(); + $this->dbConnection->commit(); + + try { + $this->getShare(new ShareAccessContext(overrideChecks: true), $id); + $this->fail('Share still exists.'); + } catch (HintException $hintException) { + $this->assertEquals('Share not found.', $hintException->getHint()); + } + } + + public function testInitiatorDeleted(): void { + $accessContext = new ShareAccessContext($this->owner); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource(TestShareSourceType1::class, 'source1')); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient(TestShareRecipientType1::class, 'recipient1', null)); + $this->manager->updateSharePermission($accessContext, $id, new SharePermission(ReshareSharePermissionType::class, true)); + $this->manager->updateShareState($accessContext, $id, ShareState::Active); + $this->manager->addShareRecipient(new ShareAccessContext(currentUser: $this->user1), $id, new ShareRecipient(TestShareRecipientType2::class, 'recipient2', null)); + + $before = $this->manager->generateTimestamp(); + $this->user1->delete(); + $after = $this->manager->generateTimestamp(); + $this->dbConnection->commit(); + + $share = $this->getShare(new ShareAccessContext(overrideChecks: true), $id); + $this->assertGreaterThanOrEqual($before, $share['last_updated']); + $this->assertLessThanOrEqual($after, $share['last_updated']); + $this->assertEquals([ + [ + 'class' => TestShareRecipientType1::class, + 'value' => 'recipient1', + 'instance' => null, + 'display_name' => 'Recipient 1', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + [ + 'class' => TestShareRecipientType2::class, + 'value' => 'recipient2', + 'instance' => null, + 'display_name' => 'Recipient 2', + 'icon' => [ + 'svg' => '', + ], + 'secret' => [ + 'updatable' => false, + ], + 'initiator' => [ + 'user_id' => 'owner', + 'instance' => null, + 'display_name' => 'Owner', + 'icon' => [ + 'light' => 'http://localhost/index.php/avatar/owner/64', + 'dark' => 'http://localhost/index.php/avatar/owner/64/dark', + ], + ], + ], + ], $share['recipients']); + } +} diff --git a/tests/lib/Sharing/Property/ABooleanSharePropertyTypeTest.php b/tests/lib/Sharing/Property/ABooleanSharePropertyTypeTest.php new file mode 100644 index 0000000000000..5b10601bcb9e9 --- /dev/null +++ b/tests/lib/Sharing/Property/ABooleanSharePropertyTypeTest.php @@ -0,0 +1,66 @@ +propertyType = new TestBooleanSharePropertyType(); + } + + public function testValidateValue(): void { + $l10nFactory = Server::get(IFactory::class); + $this->assertTrue($this->propertyType->validateValue($l10nFactory, 'true')); + $this->assertTrue($this->propertyType->validateValue($l10nFactory, 'false')); + $this->assertIsString($this->propertyType->validateValue($l10nFactory, '')); + $this->assertIsString($this->propertyType->validateValue($l10nFactory, 'invalid')); + } +} diff --git a/tests/lib/Sharing/Property/ADateSharePropertyTypeTest.php b/tests/lib/Sharing/Property/ADateSharePropertyTypeTest.php new file mode 100644 index 0000000000000..24e1718e62bc5 --- /dev/null +++ b/tests/lib/Sharing/Property/ADateSharePropertyTypeTest.php @@ -0,0 +1,90 @@ +minDate; + } + + #[\Override] + public function getMaxDate(): ?DateTimeImmutable { + return $this->maxDate; + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + throw new \RuntimeException(); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + throw new \RuntimeException(); + } + + #[\Override] + public function getPriority(): int { + throw new \RuntimeException(); + } + + #[\Override] + public function isAdvanced(): bool { + throw new \RuntimeException(); + } + + #[\Override] + public function isRequired(): bool { + throw new \RuntimeException(); + } + + #[\Override] + public function getDefaultValue(): ?string { + throw new \RuntimeException(); + } +} + +final class ADateSharePropertyTypeTest extends TestCase { + private DateTimeImmutable $now; + + private ADateSharePropertyType $propertyType; + + #[\Override] + public function setUp(): void { + parent::setUp(); + + $this->now = new DateTimeImmutable(); + $this->propertyType = new TestDateSharePropertyType( + $this->now->sub(new DateInterval('PT1M')), + $this->now->add(new DateInterval('PT1M')), + ); + } + + public function testValidateValue(): void { + $l10nFactory = Server::get(IFactory::class); + $this->assertTrue($this->propertyType->validateValue($l10nFactory, $this->now->format(DateTimeInterface::ATOM))); + $this->assertIsString($this->propertyType->validateValue($l10nFactory, $this->now->sub(new DateInterval('PT2M'))->format(DateTimeInterface::ATOM))); + $this->assertIsString($this->propertyType->validateValue($l10nFactory, $this->now->add(new DateInterval('PT2M'))->format(DateTimeInterface::ATOM))); + } +} diff --git a/tests/lib/Sharing/Property/AEnumSharePropertyTypeTest.php b/tests/lib/Sharing/Property/AEnumSharePropertyTypeTest.php new file mode 100644 index 0000000000000..451ad1d51f42f --- /dev/null +++ b/tests/lib/Sharing/Property/AEnumSharePropertyTypeTest.php @@ -0,0 +1,78 @@ + $validValues */ + public array $validValues, + ) { + } + + /** + * @return non-empty-list + */ + #[\Override] + public function getValidValues(): array { + return $this->validValues; + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + throw new \RuntimeException(); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + throw new \RuntimeException(); + } + + #[\Override] + public function getPriority(): int { + throw new \RuntimeException(); + } + + #[\Override] + public function isAdvanced(): bool { + throw new \RuntimeException(); + } + + #[\Override] + public function isRequired(): bool { + throw new \RuntimeException(); + } + + #[\Override] + public function getDefaultValue(): ?string { + throw new \RuntimeException(); + } +} + +final class AEnumSharePropertyTypeTest extends TestCase { + private AEnumSharePropertyType $propertyType; + + #[\Override] + public function setUp(): void { + parent::setUp(); + + $this->propertyType = new TestEnumSharePropertyType(['valid']); + } + + public function testValidateValue(): void { + $l10nFactory = Server::get(IFactory::class); + $this->assertTrue($this->propertyType->validateValue($l10nFactory, 'valid')); + $this->assertIsString($this->propertyType->validateValue($l10nFactory, 'invalid')); + } +} diff --git a/tests/lib/Sharing/Property/APasswordSharePropertyTypeTest.php b/tests/lib/Sharing/Property/APasswordSharePropertyTypeTest.php new file mode 100644 index 0000000000000..b54f085b1aebb --- /dev/null +++ b/tests/lib/Sharing/Property/APasswordSharePropertyTypeTest.php @@ -0,0 +1,108 @@ +propertyType = new TestPasswordSharePropertyType(); + + $this->eventDispatcher = Server::get(IEventDispatcher::class); + $this->validatePasswordPolicyEventListener = static function (ValidatePasswordPolicyEvent $event): void { + if ($event->getPassword() !== 'secure') { + throw new HintException('insecure message', 'insecure hint'); + } + }; + + $this->eventDispatcher->addListener(ValidatePasswordPolicyEvent::class, $this->validatePasswordPolicyEventListener); + } + + #[\Override] + protected function tearDown(): void { + $this->eventDispatcher->removeListener(ValidatePasswordPolicyEvent::class, $this->validatePasswordPolicyEventListener); + + parent::tearDown(); + } + + public function testValidateValue(): void { + $l10nFactory = Server::get(IFactory::class); + $this->assertTrue($this->propertyType->validateValue($l10nFactory, 'secure')); + $this->assertIsString($this->propertyType->validateValue($l10nFactory, '123')); + } + + public function testModifyValueOnFetch(): void { + $this->assertNull($this->propertyType->modifyValueOnLoad(null)); + $this->assertEquals(APasswordSharePropertyType::PLACEHOLDER, $this->propertyType->modifyValueOnLoad('')); + } + + public function testModifyValueOnSave(): void { + $this->assertNull($this->propertyType->modifyValueOnSave('old hash', null)); + + $this->assertEquals('old hash', $this->propertyType->modifyValueOnSave('old hash', APasswordSharePropertyType::PLACEHOLDER)); + + $newHash = $this->propertyType->modifyValueOnSave('old hash', 'password'); + $this->assertNotNull($newHash); + $this->assertNotEquals('old hash', $newHash); + $this->assertTrue(Server::get(IHasher::class)->verify('password', $newHash)); + } +} diff --git a/tests/lib/Sharing/Property/AStringSharePropertyTypeTest.php b/tests/lib/Sharing/Property/AStringSharePropertyTypeTest.php new file mode 100644 index 0000000000000..9568a607f1cfe --- /dev/null +++ b/tests/lib/Sharing/Property/AStringSharePropertyTypeTest.php @@ -0,0 +1,88 @@ +minLength; + } + + #[\Override] + public function getMaxLength(): ?int { + return $this->maxLength; + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + throw new \RuntimeException(); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + throw new \RuntimeException(); + } + + #[\Override] + public function getPriority(): int { + throw new \RuntimeException(); + } + + #[\Override] + public function isAdvanced(): bool { + throw new \RuntimeException(); + } + + #[\Override] + public function isRequired(): bool { + throw new \RuntimeException(); + } + + #[\Override] + public function getDefaultValue(): ?string { + throw new \RuntimeException(); + } +} + +final class AStringSharePropertyTypeTest extends TestCase { + private AStringSharePropertyType $propertyType; + + #[\Override] + public function setUp(): void { + parent::setUp(); + + $this->propertyType = new TestStringSharePropertyType( + 3, + 5, + ); + } + + public function testValiStringValue(): void { + $l10nFactory = Server::get(IFactory::class); + $this->assertIsString($this->propertyType->validateValue($l10nFactory, 'ab')); + $this->assertTrue($this->propertyType->validateValue($l10nFactory, 'abc')); + $this->assertTrue($this->propertyType->validateValue($l10nFactory, 'abcd')); + $this->assertTrue($this->propertyType->validateValue($l10nFactory, 'abcde')); + $this->assertIsString($this->propertyType->validateValue($l10nFactory, 'abcdef')); + } +} diff --git a/tests/lib/Sharing/SharingManagerTest.php b/tests/lib/Sharing/SharingManagerTest.php new file mode 100644 index 0000000000000..fdf9abb0a6401 --- /dev/null +++ b/tests/lib/Sharing/SharingManagerTest.php @@ -0,0 +1,218 @@ +registry, Server::get(IFactory::class), Server::get(IURLGenerator::class), Server::get(IUserManager::class), $this->manager->searchRecipients($accessContext, $recipientTypeClasses, $query, $limit, $offset)); + } + + #[\Override] + protected function createShare(ShareAccessContext $accessContext): array { + try { + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $share = $this->manager->getShare($accessContext, $id)->format($this->registry, Server::get(IFactory::class), Server::get(IURLGenerator::class), Server::get(IUserManager::class)); + $this->dbConnection->commit(); + return $share; + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } + + #[ + \Override] + protected function updateShareState(ShareAccessContext $accessContext, string $id, ShareState $state): array { + try { + $this->dbConnection->beginTransaction(); + $this->manager->updateShareState($accessContext, $id, $state); + $share = $this->manager->getShare($accessContext, $id)->format($this->registry, Server::get(IFactory::class), Server::get(IURLGenerator::class), Server::get(IUserManager::class)); + $this->dbConnection->commit(); + return $share; + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } + + #[\Override] + protected function addShareSource(ShareAccessContext $accessContext, string $id, ShareSource $source): array { + try { + $this->dbConnection->beginTransaction(); + $this->manager->addShareSource($accessContext, $id, $source); + $share = $this->manager->getShare($accessContext, $id)->format($this->registry, Server::get(IFactory::class), Server::get(IURLGenerator::class), Server::get(IUserManager::class)); + $this->dbConnection->commit(); + return $share; + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } + + #[\Override] + protected function removeShareSource(ShareAccessContext $accessContext, string $id, ShareSource $source): array { + try { + $this->dbConnection->beginTransaction(); + $this->manager->removeShareSource($accessContext, $id, $source); + $share = $this->manager->getShare($accessContext, $id)->format($this->registry, Server::get(IFactory::class), Server::get(IURLGenerator::class), Server::get(IUserManager::class)); + $this->dbConnection->commit(); + return $share; + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } + + #[\Override] + protected function addShareRecipient(ShareAccessContext $accessContext, string $id, ShareRecipient $recipient): array { + try { + $this->dbConnection->beginTransaction(); + $this->manager->addShareRecipient($accessContext, $id, $recipient); + $share = $this->manager->getShare($accessContext, $id)->format($this->registry, Server::get(IFactory::class), Server::get(IURLGenerator::class), Server::get(IUserManager::class)); + $this->dbConnection->commit(); + return $share; + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } + + #[\Override] + protected function removeShareRecipient(ShareAccessContext $accessContext, string $id, ShareRecipient $recipient): array { + try { + $this->dbConnection->beginTransaction(); + $this->manager->removeShareRecipient($accessContext, $id, $recipient); + $share = $this->manager->getShare($accessContext, $id)->format($this->registry, Server::get(IFactory::class), Server::get(IURLGenerator::class), Server::get(IUserManager::class)); + $this->dbConnection->commit(); + return $share; + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } + + #[\Override] + protected function updateShareRecipientSecret(ShareAccessContext $accessContext, string $id, ShareRecipient $recipient, string $secret): array { + try { + $this->dbConnection->beginTransaction(); + $this->manager->updateShareRecipientSecret($accessContext, $id, $recipient, $secret); + $share = $this->manager->getShare($accessContext, $id)->format($this->registry, Server::get(IFactory::class), Server::get(IURLGenerator::class), Server::get(IUserManager::class)); + $this->dbConnection->commit(); + return $share; + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } + + #[\Override] + protected function updateShareProperty(ShareAccessContext $accessContext, string $id, ShareProperty $property): array { + try { + $this->dbConnection->beginTransaction(); + $this->manager->updateShareProperty($accessContext, $id, $property); + $share = $this->manager->getShare($accessContext, $id)->format($this->registry, Server::get(IFactory::class), Server::get(IURLGenerator::class), Server::get(IUserManager::class)); + $this->dbConnection->commit(); + return $share; + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } + + #[\Override] + protected function updateSharePermission(ShareAccessContext $accessContext, string $id, SharePermission $permission): array { + try { + $this->dbConnection->beginTransaction(); + $this->manager->updateSharePermission($accessContext, $id, $permission); + $share = $this->manager->getShare($accessContext, $id)->format($this->registry, Server::get(IFactory::class), Server::get(IURLGenerator::class), Server::get(IUserManager::class)); + $this->dbConnection->commit(); + return $share; + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } + + #[\Override] + protected function selectSharePermissionPreset(ShareAccessContext $accessContext, string $id, string $permissionPresetClass): array { + try { + $this->dbConnection->beginTransaction(); + /** @psalm-suppress ArgumentTypeCoercion */ + $this->manager->selectSharePermissionPreset($accessContext, $id, $permissionPresetClass); + $share = $this->manager->getShare($accessContext, $id)->format($this->registry, Server::get(IFactory::class), Server::get(IURLGenerator::class), Server::get(IUserManager::class)); + $this->dbConnection->commit(); + return $share; + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } + + #[\Override] + protected function deleteShare(ShareAccessContext $accessContext, string $id): void { + try { + $this->dbConnection->beginTransaction(); + $this->manager->deleteShare($accessContext, $id); + $this->dbConnection->commit(); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } + + #[\Override] + protected function getShare(ShareAccessContext $accessContext, string $id): array { + try { + $this->dbConnection->beginTransaction(); + $share = $this->manager->getShare($accessContext, $id)->format($this->registry, Server::get(IFactory::class), Server::get(IURLGenerator::class), Server::get(IUserManager::class)); + $this->dbConnection->commit(); + return $share; + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } + + /** + * @return mixed[][] + */ + #[\Override] + protected function getShares(ShareAccessContext $accessContext, ?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?string $lastShareID, ?int $limit): array { + try { + $this->dbConnection->beginTransaction(); + /** @psalm-suppress ArgumentTypeCoercion */ + $shares = $this->manager->getShares($accessContext, $filterSourceTypeClass, $filterSourceTypeValue, $lastShareID, $limit); + $this->dbConnection->commit(); + return Share::formatMultiple($this->registry, Server::get(IFactory::class), Server::get(IURLGenerator::class), Server::get(IUserManager::class), $shares); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } +} diff --git a/tests/lib/Sharing/SharingRegistryTest.php b/tests/lib/Sharing/SharingRegistryTest.php new file mode 100644 index 0000000000000..d78984ea644f3 --- /dev/null +++ b/tests/lib/Sharing/SharingRegistryTest.php @@ -0,0 +1,171 @@ +registry = Server::get(ISharingRegistry::class); + $this->registry->clear(); + } + + #[\Override] + protected function tearDown(): void { + $this->registry->clear(); + + parent::tearDown(); + } + + public function testClear(): void { + $this->registry->registerSourceType(new TestShareSourceType1([])); + $this->registry->registerRecipientType(new TestShareRecipientType1([], [], [])); + $this->registry->registerPropertyType(new TestSharePropertyType1([''])); + $this->registry->markPropertyTypeCompatibleWithSourceType(TestSharePropertyType1::class, TestShareSourceType1::class); + $this->registry->markPropertyTypeCompatibleWithRecipientType(TestSharePropertyType1::class, TestShareRecipientType1::class); + $this->registry->registerPermissionType(TestShareSourceType1::class, new TestSharePermissionType1()); + $this->registry->registerPermissionPreset(new TestSharePermissionPreset1()); + $this->registry->markPermissionTypeCompatibleWithPermissionPreset(TestSharePermissionType1::class, TestSharePermissionPreset1::class); + + $this->registry->clear(); + + $this->assertEquals([], $this->registry->getSourceTypes()); + $this->assertEquals([], $this->registry->getRecipientTypes()); + $this->assertEquals([], $this->registry->getPropertyTypes()); + $this->assertEquals([], $this->registry->getPropertyTypeCompatibleSourceTypeClasses()); + $this->assertEquals([], $this->registry->getPropertyTypeCompatibleRecipientTypes()); + $this->assertEquals([], $this->registry->getPermissionTypes()); + $this->assertEquals([], $this->registry->getPermissionPresets()); + $this->assertEquals([], $this->registry->getPermissionTypeCompatiblePermissionPresetClasses()); + $this->assertEquals([], $this->registry->getPermissionPresetCompatiblePermissionTypeClasses()); + } + + public function testRegisterSourceType(): void { + $sourceType = new TestShareSourceType1([]); + $this->registry->registerSourceType($sourceType); + + $this->assertEquals([$sourceType::class => $sourceType], $this->registry->getSourceTypes()); + + $this->expectException(RuntimeException::class); + $this->registry->registerSourceType($sourceType); + } + + public function testRegisterRecipientType(): void { + $recipientType = new TestShareRecipientType1([], [], []); + $this->registry->registerRecipientType($recipientType); + + $this->assertEquals([$recipientType::class => $recipientType], $this->registry->getRecipientTypes()); + + $this->expectException(RuntimeException::class); + $this->registry->registerRecipientType($recipientType); + } + + public function testRegisterProperty(): void { + $sourceType = new TestShareSourceType1([]); + $this->registry->registerSourceType($sourceType); + + $recipientType = new TestShareRecipientType1([], [], []); + $this->registry->registerRecipientType($recipientType); + + $propertyType = new TestSharePropertyType1(['']); + $this->registry->registerPropertyType($propertyType); + $this->registry->markPropertyTypeCompatibleWithSourceType($propertyType::class, $sourceType::class); + $this->registry->markPropertyTypeCompatibleWithRecipientType($propertyType::class, $recipientType::class); + + $this->assertEquals([$propertyType::class => $propertyType], $this->registry->getPropertyTypes()); + + $this->expectException(RuntimeException::class); + $this->registry->registerPropertyType($propertyType); + } + + public function testMarkPropertyCompatibleWithSourceType(): void { + $sourceType = new TestShareSourceType1([]); + $this->registry->registerSourceType($sourceType); + + $propertyType = new TestSharePropertyType1(['']); + $this->registry->registerPropertyType($propertyType); + $this->registry->markPropertyTypeCompatibleWithSourceType($propertyType::class, $sourceType::class); + $this->registry->markPropertyTypeCompatibleWithSourceType($propertyType::class, $sourceType::class); + + $this->assertEquals([$propertyType::class => [$sourceType::class]], $this->registry->getPropertyTypeCompatibleSourceTypeClasses()); + + $this->registry->markPropertyTypeCompatibleWithSourceType($propertyType::class, TestShareSourceType2::class); + $this->expectException(RuntimeException::class); + $this->registry->getPropertyTypeCompatibleSourceTypeClasses(); + } + + public function testMarkPropertyCompatibleWithRecipientType(): void { + $recipientType = new TestShareRecipientType1([], [], []); + $this->registry->registerRecipientType($recipientType); + + $propertyType = new TestSharePropertyType1(['']); + $this->registry->registerPropertyType($propertyType); + $this->registry->markPropertyTypeCompatibleWithRecipientType($propertyType::class, $recipientType::class); + $this->registry->markPropertyTypeCompatibleWithRecipientType($propertyType::class, $recipientType::class); + + $this->assertEquals([$propertyType::class => [$recipientType::class]], $this->registry->getPropertyTypeCompatibleRecipientTypes()); + + $this->registry->markPropertyTypeCompatibleWithRecipientType($propertyType::class, TestShareRecipientType2::class); + $this->expectException(RuntimeException::class); + $this->registry->getPropertyTypeCompatibleRecipientTypes(); + } + + public function testRegisterPermission(): void { + $sourceType = new TestShareSourceType1([]); + $this->registry->registerSourceType($sourceType); + + $permissionType1 = new TestSharePermissionType1(); + $this->registry->registerPermissionType($sourceType::class, $permissionType1); + $permissionType2 = new TestSharePermissionType2(); + $this->registry->registerPermissionType(null, $permissionType2); + + $this->assertEquals([$permissionType1::class => $permissionType1, $permissionType2::class => $permissionType2], $this->registry->getPermissionTypes()); + $this->assertEquals([$sourceType::class => [$permissionType1::class]], $this->registry->getSourceTypePermissionTypeClasses()); + $this->assertEquals([$permissionType2::class], $this->registry->getGenericPermissionTypeClasses()); + + $this->expectException(RuntimeException::class); + $this->registry->registerPermissionType($sourceType::class, $permissionType1); + } + + public function testRegisterPermissionPreset(): void { + $permissionPreset = new TestSharePermissionPreset1(); + $this->registry->registerPermissionPreset($permissionPreset); + + $this->assertEquals([$permissionPreset::class => $permissionPreset], $this->registry->getPermissionPresets()); + + $this->expectException(RuntimeException::class); + $this->registry->registerPermissionPreset($permissionPreset); + } + + public function testMarkPermissionTypeCompatibleWithPermissionPreset(): void { + $permissionPreset = new TestSharePermissionPreset1(); + $this->registry->registerPermissionPreset($permissionPreset); + + $permissionType = new TestSharePermissionType1(); + $this->registry->registerPermissionType(null, $permissionType); + $this->registry->markPermissionTypeCompatibleWithPermissionPreset($permissionType::class, $permissionPreset::class); + $this->registry->markPermissionTypeCompatibleWithPermissionPreset($permissionType::class, $permissionPreset::class); + + $this->assertEquals([$permissionType::class => [$permissionPreset::class]], $this->registry->getPermissionTypeCompatiblePermissionPresetClasses()); + $this->assertEquals([$permissionPreset::class => [$permissionType::class]], $this->registry->getPermissionPresetCompatiblePermissionTypeClasses()); + + $this->registry->markPermissionTypeCompatibleWithPermissionPreset($permissionType::class, TestSharePermissionPreset2::class); + $this->expectException(RuntimeException::class); + $this->registry->getPermissionTypeCompatiblePermissionPresetClasses(); + } +} diff --git a/tests/lib/Sharing/TestInteractionReceiver.php b/tests/lib/Sharing/TestInteractionReceiver.php new file mode 100644 index 0000000000000..fac0ac7ac1799 --- /dev/null +++ b/tests/lib/Sharing/TestInteractionReceiver.php @@ -0,0 +1,24 @@ +recipient; + } +} diff --git a/tests/lib/Sharing/TestInteractionResource.php b/tests/lib/Sharing/TestInteractionResource.php new file mode 100644 index 0000000000000..fbe8054c0f23c --- /dev/null +++ b/tests/lib/Sharing/TestInteractionResource.php @@ -0,0 +1,24 @@ +source; + } +} diff --git a/tests/lib/Sharing/TestSharePermissionPreset1.php b/tests/lib/Sharing/TestSharePermissionPreset1.php new file mode 100644 index 0000000000000..529db4fd41617 --- /dev/null +++ b/tests/lib/Sharing/TestSharePermissionPreset1.php @@ -0,0 +1,27 @@ + $parts */ + $parts = explode('\\', static::class); + return end($parts); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + return 'hint ' . $this->getDisplayName($l10nFactory); + } +} diff --git a/tests/lib/Sharing/TestSharePermissionPreset2.php b/tests/lib/Sharing/TestSharePermissionPreset2.php new file mode 100644 index 0000000000000..65c2d1f93f5da --- /dev/null +++ b/tests/lib/Sharing/TestSharePermissionPreset2.php @@ -0,0 +1,13 @@ + $parts */ + $parts = explode('\\', static::class); + return end($parts); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): string { + return 'hint ' . $this->getDisplayName($l10nFactory); + } + + #[\Override] + public function getPriority(): int { + return 1; + } + + #[\Override] + public function isEnabledByDefault(): bool { + return false; + } +} diff --git a/tests/lib/Sharing/TestSharePermissionType2.php b/tests/lib/Sharing/TestSharePermissionType2.php new file mode 100644 index 0000000000000..db69e5ff38136 --- /dev/null +++ b/tests/lib/Sharing/TestSharePermissionType2.php @@ -0,0 +1,13 @@ + $validValues */ + private readonly array $validValues, + ) { + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + /** @var non-empty-list $parts */ + $parts = explode('\\', static::class); + return end($parts); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): string { + return 'hint ' . $this->getDisplayName($l10nFactory); + } + + #[\Override] + public function getPriority(): int { + return 1; + } + + #[\Override] + public function isAdvanced(): bool { + return false; + } + + #[\Override] + public function isRequired(): bool { + return false; + } + + #[\Override] + public function getDefaultValue(): ?string { + return null; + } + + /** + * @return non-empty-list + */ + #[\Override] + public function getValidValues(): array { + return $this->validValues; + } +} diff --git a/tests/lib/Sharing/TestSharePropertyType2.php b/tests/lib/Sharing/TestSharePropertyType2.php new file mode 100644 index 0000000000000..d67f35204df61 --- /dev/null +++ b/tests/lib/Sharing/TestSharePropertyType2.php @@ -0,0 +1,13 @@ +arguments[self::class] ?? null) === 'filtered') { + return true; + } + + return ($property = $share->properties[self::class] ?? null) !== null && $property->value === 'filtered'; + } +} diff --git a/tests/lib/Sharing/TestSharePropertyTypeModifyValue.php b/tests/lib/Sharing/TestSharePropertyTypeModifyValue.php new file mode 100644 index 0000000000000..b3ff114b671d6 --- /dev/null +++ b/tests/lib/Sharing/TestSharePropertyTypeModifyValue.php @@ -0,0 +1,36 @@ +getValidValues()[0]; + } + + #[\Override] + public function isRequired(): bool { + return true; + } +} diff --git a/tests/lib/Sharing/TestShareRecipientType1.php b/tests/lib/Sharing/TestShareRecipientType1.php new file mode 100644 index 0000000000000..b318ef3d3712d --- /dev/null +++ b/tests/lib/Sharing/TestShareRecipientType1.php @@ -0,0 +1,79 @@ + $validRecipients */ + private readonly array $validRecipients, + /** @var array> $recipientValues */ + private readonly array $recipientValues, + /** @var list $searchRecipients */ + public array $searchRecipients, + ) { + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + /** @var non-empty-list $parts */ + $parts = explode('\\', static::class); + return end($parts); + } + + #[\Override] + public function validateRecipient(string $recipient): bool { + return array_key_exists($recipient, $this->validRecipients); + } + + /** + * @return list + */ + #[\Override] + public function getRecipients(?IUser $currentUser, mixed $arguments): array { + if (!$currentUser instanceof IUser) { + return []; + } + + return $this->recipientValues[$currentUser->getUID()] ?? []; + } + + #[\Override] + public function getRecipientDisplayName(string $recipient): ?string { + return $this->validRecipients[$recipient]; + } + + #[\Override] + public function getRecipientIcon(string $recipient): null|ShareIconSVG|ShareIconURL { + return match ($recipient) { + 'url' => new ShareIconURL('https://example.com/light.png', 'https://example.com/dark.png'), + default => new ShareIconSVG(''), + }; + } + + #[\Override] + public function getRecipientInteractionReceiver(string $recipient): InteractionReceiver { + return new TestInteractionReceiver($recipient); + } + + #[\Override] + public function searchRecipients(ShareAccessContext $accessContext, string $query, int $limit, int $offset): array { + return array_slice($this->searchRecipients, $offset, $limit); + } +} diff --git a/tests/lib/Sharing/TestShareRecipientType2.php b/tests/lib/Sharing/TestShareRecipientType2.php new file mode 100644 index 0000000000000..c9c390854f1c8 --- /dev/null +++ b/tests/lib/Sharing/TestShareRecipientType2.php @@ -0,0 +1,13 @@ + $parts */ + $parts = explode('\\', self::class); + return end($parts); + } + + #[\Override] + public function validateRecipient(string $recipient): bool { + return true; + } + + #[\Override] + public function getRecipients(?IUser $currentUser, mixed $arguments): array { + if (is_string($arguments)) { + return [$arguments]; + } + + return []; + } + + #[\Override] + public function getRecipientDisplayName(string $recipient): ?string { + return null; + } + + #[\Override] + public function getRecipientIcon(string $recipient): null|ShareIconSVG|ShareIconURL { + return null; + } + + #[\Override] + public function getRecipientInteractionReceiver(string $recipient): InteractionReceiver { + return new TestInteractionReceiver($recipient); + } +} diff --git a/tests/lib/Sharing/TestShareRecipientTypePublicSecret.php b/tests/lib/Sharing/TestShareRecipientTypePublicSecret.php new file mode 100644 index 0000000000000..b50dc24695f67 --- /dev/null +++ b/tests/lib/Sharing/TestShareRecipientTypePublicSecret.php @@ -0,0 +1,82 @@ + $validRecipients */ + private array $validRecipients, + /** @var array> $recipientValues */ + private array $recipientValues, + private bool $isSecretPublic, + private bool $isSecretUpdatable, + ) { + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + /** @var non-empty-list $parts */ + $parts = explode('\\', self::class); + return end($parts); + } + + #[\Override] + public function validateRecipient(string $recipient): bool { + return array_key_exists($recipient, $this->validRecipients); + } + + /** + * @return list + */ + #[\Override] + public function getRecipients(?IUser $currentUser, mixed $arguments): array { + if (!$currentUser instanceof IUser) { + return []; + } + + return $this->recipientValues[$currentUser->getUID()] ?? []; + } + + #[\Override] + public function getRecipientDisplayName(string $recipient): ?string { + return $this->validRecipients[$recipient]; + } + + #[\Override] + public function getRecipientIcon(string $recipient): ShareIconSVG|ShareIconURL { + return match ($recipient) { + 'url' => new ShareIconURL('https://example.com/light.png', 'https://example.com/dark.png'), + default => new ShareIconSVG(''), + }; + } + + #[\Override] + public function isSecretPublic(string $recipient): bool { + return $this->isSecretPublic; + } + + #[\Override] + public function isSecretUpdatable(string $recipient): bool { + return $this->isSecretUpdatable; + } + + #[\Override] + public function getRecipientInteractionReceiver(string $recipient): InteractionReceiver { + return new TestInteractionReceiver($recipient); + } +} diff --git a/tests/lib/Sharing/TestShareSourceType1.php b/tests/lib/Sharing/TestShareSourceType1.php new file mode 100644 index 0000000000000..55b2e865607a7 --- /dev/null +++ b/tests/lib/Sharing/TestShareSourceType1.php @@ -0,0 +1,51 @@ + $validSources */ + private readonly array $validSources, + ) { + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + /** @var non-empty-list $parts */ + $parts = explode('\\', static::class); + return end($parts); + } + + #[\Override] + public function validateSource(string $source): bool { + return array_key_exists($source, $this->validSources); + } + + #[\Override] + public function getSourceDisplayName(string $source): ?string { + return $this->validSources[$source]; + } + + #[\Override] + public function getSourceIcon(string $source): null|ShareIconSVG|ShareIconURL { + return new ShareIconSVG(''); + } + + #[\Override] + public function getSourceInteractionResource(string $userId, string $source): InteractionResource { + return new TestInteractionResource($source); + } +} diff --git a/tests/lib/Sharing/TestShareSourceType2.php b/tests/lib/Sharing/TestShareSourceType2.php new file mode 100644 index 0000000000000..5f9913d7a1a5e --- /dev/null +++ b/tests/lib/Sharing/TestShareSourceType2.php @@ -0,0 +1,13 @@ + Date: Mon, 4 May 2026 13:43:44 +0200 Subject: [PATCH 2/5] feat(sharing): Init Signed-off-by: provokateurin --- .gitignore | 1 + REUSE.toml | 2 +- apps/sharing/appinfo/info.xml | 34 + apps/sharing/composer/autoload.php | 22 + apps/sharing/composer/composer.json | 13 + apps/sharing/composer/composer.lock | 18 + .../sharing/composer/composer/ClassLoader.php | 579 +++ .../composer/composer/InstalledVersions.php | 396 ++ apps/sharing/composer/composer/LICENSE | 21 + .../composer/composer/autoload_classmap.php | 31 + .../composer/composer/autoload_namespaces.php | 9 + .../composer/composer/autoload_psr4.php | 10 + .../composer/composer/autoload_real.php | 37 + .../composer/composer/autoload_static.php | 57 + apps/sharing/composer/composer/installed.json | 5 + apps/sharing/composer/composer/installed.php | 23 + apps/sharing/lib/AppInfo/Application.php | 42 + apps/sharing/lib/Capabilities.php | 65 + .../sharing/lib/Command/AddShareRecipient.php | 47 + apps/sharing/lib/Command/AddShareSource.php | 43 + apps/sharing/lib/Command/CreateShare.php | 41 + apps/sharing/lib/Command/DeleteShare.php | 54 + apps/sharing/lib/Command/GetShare.php | 32 + apps/sharing/lib/Command/GetShares.php | 62 + .../lib/Command/RemoveShareRecipient.php | 46 + .../sharing/lib/Command/RemoveShareSource.php | 43 + .../Command/SelectSharePermissionPreset.php | 39 + apps/sharing/lib/Command/SharingBase.php | 69 + .../lib/Command/UpdateSharePermission.php | 44 + .../lib/Command/UpdateShareProperty.php | 43 + .../Command/UpdateShareRecipientSecret.php | 49 + apps/sharing/lib/Command/UpdateShareState.php | 40 + .../lib/Controller/ApiV1Controller.php | 586 +++ .../Middleware/ShareApiEnabledMiddleware.php | 26 + .../Version1000Date20250929161325.php | 77 + apps/sharing/lib/ResponseDefinitions.php | 136 + apps/sharing/lib/SharingBackend.php | 953 +++++ apps/sharing/openapi.json | 3235 +++++++++++++++++ apps/sharing/tests/CapabilitiesTest.php | 77 + apps/sharing/tests/Command/CommandTest.php | 393 ++ .../tests/Controller/ApiV1ControllerTest.php | 166 + build/rector-strict.php | 1 + core/shipped.json | 2 + openapi.json | 3186 ++++++++++++++++ psalm-strict.xml | 1 + psalm.xml | 1 + 46 files changed, 10856 insertions(+), 1 deletion(-) create mode 100644 apps/sharing/appinfo/info.xml create mode 100644 apps/sharing/composer/autoload.php create mode 100644 apps/sharing/composer/composer.json create mode 100644 apps/sharing/composer/composer.lock create mode 100644 apps/sharing/composer/composer/ClassLoader.php create mode 100644 apps/sharing/composer/composer/InstalledVersions.php create mode 100644 apps/sharing/composer/composer/LICENSE create mode 100644 apps/sharing/composer/composer/autoload_classmap.php create mode 100644 apps/sharing/composer/composer/autoload_namespaces.php create mode 100644 apps/sharing/composer/composer/autoload_psr4.php create mode 100644 apps/sharing/composer/composer/autoload_real.php create mode 100644 apps/sharing/composer/composer/autoload_static.php create mode 100644 apps/sharing/composer/composer/installed.json create mode 100644 apps/sharing/composer/composer/installed.php create mode 100644 apps/sharing/lib/AppInfo/Application.php create mode 100644 apps/sharing/lib/Capabilities.php create mode 100644 apps/sharing/lib/Command/AddShareRecipient.php create mode 100644 apps/sharing/lib/Command/AddShareSource.php create mode 100644 apps/sharing/lib/Command/CreateShare.php create mode 100644 apps/sharing/lib/Command/DeleteShare.php create mode 100644 apps/sharing/lib/Command/GetShare.php create mode 100644 apps/sharing/lib/Command/GetShares.php create mode 100644 apps/sharing/lib/Command/RemoveShareRecipient.php create mode 100644 apps/sharing/lib/Command/RemoveShareSource.php create mode 100644 apps/sharing/lib/Command/SelectSharePermissionPreset.php create mode 100644 apps/sharing/lib/Command/SharingBase.php create mode 100644 apps/sharing/lib/Command/UpdateSharePermission.php create mode 100644 apps/sharing/lib/Command/UpdateShareProperty.php create mode 100644 apps/sharing/lib/Command/UpdateShareRecipientSecret.php create mode 100644 apps/sharing/lib/Command/UpdateShareState.php create mode 100644 apps/sharing/lib/Controller/ApiV1Controller.php create mode 100644 apps/sharing/lib/Middleware/ShareApiEnabledMiddleware.php create mode 100644 apps/sharing/lib/Migration/Version1000Date20250929161325.php create mode 100644 apps/sharing/lib/ResponseDefinitions.php create mode 100644 apps/sharing/lib/SharingBackend.php create mode 100644 apps/sharing/openapi.json create mode 100644 apps/sharing/tests/CapabilitiesTest.php create mode 100644 apps/sharing/tests/Command/CommandTest.php create mode 100644 apps/sharing/tests/Controller/ApiV1ControllerTest.php diff --git a/.gitignore b/.gitignore index a53c847f4a1da..3836a0ac18e29 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ node_modules/ !/apps/provisioning_api !/apps/settings !/apps/sharebymail +!/apps/sharing !/apps/systemtags !/apps/testing !/apps/updatenotification diff --git a/REUSE.toml b/REUSE.toml index ffc5fed40a814..f9c27b0cc8f97 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -406,7 +406,7 @@ SPDX-FileCopyrightText = "2019 Fabian Wiktor + + + sharing + Sharing + TODO + TODO + 1.0.0 + AGPL-3.0-or-later + Kate Döen + Sharing + customization + https://github.com/nextcloud/server/issues + + + + + \OCA\Sharing\Command\AddShareRecipient + \OCA\Sharing\Command\AddShareSource + \OCA\Sharing\Command\CreateShare + \OCA\Sharing\Command\DeleteShare + \OCA\Sharing\Command\GetShare + \OCA\Sharing\Command\GetShares + \OCA\Sharing\Command\RemoveShareRecipient + \OCA\Sharing\Command\RemoveShareSource + \OCA\Sharing\Command\UpdateSharePermission + \OCA\Sharing\Command\UpdateShareProperty + \OCA\Sharing\Command\UpdateShareState + + diff --git a/apps/sharing/composer/autoload.php b/apps/sharing/composer/autoload.php new file mode 100644 index 0000000000000..1a2a44955cc0e --- /dev/null +++ b/apps/sharing/composer/autoload.php @@ -0,0 +1,22 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Autoload; + +/** + * ClassLoader implements a PSR-0, PSR-4 and classmap class loader. + * + * $loader = new \Composer\Autoload\ClassLoader(); + * + * // register classes with namespaces + * $loader->add('Symfony\Component', __DIR__.'/component'); + * $loader->add('Symfony', __DIR__.'/framework'); + * + * // activate the autoloader + * $loader->register(); + * + * // to enable searching the include path (eg. for PEAR packages) + * $loader->setUseIncludePath(true); + * + * In this example, if you try to use a class in the Symfony\Component + * namespace or one of its children (Symfony\Component\Console for instance), + * the autoloader will first look for the class under the component/ + * directory, and it will then fallback to the framework/ directory if not + * found before giving up. + * + * This class is loosely based on the Symfony UniversalClassLoader. + * + * @author Fabien Potencier + * @author Jordi Boggiano + * @see https://www.php-fig.org/psr/psr-0/ + * @see https://www.php-fig.org/psr/psr-4/ + */ +class ClassLoader +{ + /** @var \Closure(string):void */ + private static $includeFile; + + /** @var string|null */ + private $vendorDir; + + // PSR-4 + /** + * @var array> + */ + private $prefixLengthsPsr4 = array(); + /** + * @var array> + */ + private $prefixDirsPsr4 = array(); + /** + * @var list + */ + private $fallbackDirsPsr4 = array(); + + // PSR-0 + /** + * List of PSR-0 prefixes + * + * Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2'))) + * + * @var array>> + */ + private $prefixesPsr0 = array(); + /** + * @var list + */ + private $fallbackDirsPsr0 = array(); + + /** @var bool */ + private $useIncludePath = false; + + /** + * @var array + */ + private $classMap = array(); + + /** @var bool */ + private $classMapAuthoritative = false; + + /** + * @var array + */ + private $missingClasses = array(); + + /** @var string|null */ + private $apcuPrefix; + + /** + * @var array + */ + private static $registeredLoaders = array(); + + /** + * @param string|null $vendorDir + */ + public function __construct($vendorDir = null) + { + $this->vendorDir = $vendorDir; + self::initializeIncludeClosure(); + } + + /** + * @return array> + */ + public function getPrefixes() + { + if (!empty($this->prefixesPsr0)) { + return call_user_func_array('array_merge', array_values($this->prefixesPsr0)); + } + + return array(); + } + + /** + * @return array> + */ + public function getPrefixesPsr4() + { + return $this->prefixDirsPsr4; + } + + /** + * @return list + */ + public function getFallbackDirs() + { + return $this->fallbackDirsPsr0; + } + + /** + * @return list + */ + public function getFallbackDirsPsr4() + { + return $this->fallbackDirsPsr4; + } + + /** + * @return array Array of classname => path + */ + public function getClassMap() + { + return $this->classMap; + } + + /** + * @param array $classMap Class to filename map + * + * @return void + */ + public function addClassMap(array $classMap) + { + if ($this->classMap) { + $this->classMap = array_merge($this->classMap, $classMap); + } else { + $this->classMap = $classMap; + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, either + * appending or prepending to the ones previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 root directories + * @param bool $prepend Whether to prepend the directories + * + * @return void + */ + public function add($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + if ($prepend) { + $this->fallbackDirsPsr0 = array_merge( + $paths, + $this->fallbackDirsPsr0 + ); + } else { + $this->fallbackDirsPsr0 = array_merge( + $this->fallbackDirsPsr0, + $paths + ); + } + + return; + } + + $first = $prefix[0]; + if (!isset($this->prefixesPsr0[$first][$prefix])) { + $this->prefixesPsr0[$first][$prefix] = $paths; + + return; + } + if ($prepend) { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $paths, + $this->prefixesPsr0[$first][$prefix] + ); + } else { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $this->prefixesPsr0[$first][$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, either + * appending or prepending to the ones previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * @param bool $prepend Whether to prepend the directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function addPsr4($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + // Register directories for the root namespace. + if ($prepend) { + $this->fallbackDirsPsr4 = array_merge( + $paths, + $this->fallbackDirsPsr4 + ); + } else { + $this->fallbackDirsPsr4 = array_merge( + $this->fallbackDirsPsr4, + $paths + ); + } + } elseif (!isset($this->prefixDirsPsr4[$prefix])) { + // Register directories for a new namespace. + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = $paths; + } elseif ($prepend) { + // Prepend directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $paths, + $this->prefixDirsPsr4[$prefix] + ); + } else { + // Append directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $this->prefixDirsPsr4[$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, + * replacing any others previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 base directories + * + * @return void + */ + public function set($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr0 = (array) $paths; + } else { + $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths; + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, + * replacing any others previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function setPsr4($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr4 = (array) $paths; + } else { + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = (array) $paths; + } + } + + /** + * Turns on searching the include path for class files. + * + * @param bool $useIncludePath + * + * @return void + */ + public function setUseIncludePath($useIncludePath) + { + $this->useIncludePath = $useIncludePath; + } + + /** + * Can be used to check if the autoloader uses the include path to check + * for classes. + * + * @return bool + */ + public function getUseIncludePath() + { + return $this->useIncludePath; + } + + /** + * Turns off searching the prefix and fallback directories for classes + * that have not been registered with the class map. + * + * @param bool $classMapAuthoritative + * + * @return void + */ + public function setClassMapAuthoritative($classMapAuthoritative) + { + $this->classMapAuthoritative = $classMapAuthoritative; + } + + /** + * Should class lookup fail if not found in the current class map? + * + * @return bool + */ + public function isClassMapAuthoritative() + { + return $this->classMapAuthoritative; + } + + /** + * APCu prefix to use to cache found/not-found classes, if the extension is enabled. + * + * @param string|null $apcuPrefix + * + * @return void + */ + public function setApcuPrefix($apcuPrefix) + { + $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null; + } + + /** + * The APCu prefix in use, or null if APCu caching is not enabled. + * + * @return string|null + */ + public function getApcuPrefix() + { + return $this->apcuPrefix; + } + + /** + * Registers this instance as an autoloader. + * + * @param bool $prepend Whether to prepend the autoloader or not + * + * @return void + */ + public function register($prepend = false) + { + spl_autoload_register(array($this, 'loadClass'), true, $prepend); + + if (null === $this->vendorDir) { + return; + } + + if ($prepend) { + self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders; + } else { + unset(self::$registeredLoaders[$this->vendorDir]); + self::$registeredLoaders[$this->vendorDir] = $this; + } + } + + /** + * Unregisters this instance as an autoloader. + * + * @return void + */ + public function unregister() + { + spl_autoload_unregister(array($this, 'loadClass')); + + if (null !== $this->vendorDir) { + unset(self::$registeredLoaders[$this->vendorDir]); + } + } + + /** + * Loads the given class or interface. + * + * @param string $class The name of the class + * @return true|null True if loaded, null otherwise + */ + public function loadClass($class) + { + if ($file = $this->findFile($class)) { + $includeFile = self::$includeFile; + $includeFile($file); + + return true; + } + + return null; + } + + /** + * Finds the path to the file where the class is defined. + * + * @param string $class The name of the class + * + * @return string|false The path if found, false otherwise + */ + public function findFile($class) + { + // class map lookup + if (isset($this->classMap[$class])) { + return $this->classMap[$class]; + } + if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) { + return false; + } + if (null !== $this->apcuPrefix) { + $file = apcu_fetch($this->apcuPrefix.$class, $hit); + if ($hit) { + return $file; + } + } + + $file = $this->findFileWithExtension($class, '.php'); + + // Search for Hack files if we are running on HHVM + if (false === $file && defined('HHVM_VERSION')) { + $file = $this->findFileWithExtension($class, '.hh'); + } + + if (null !== $this->apcuPrefix) { + apcu_add($this->apcuPrefix.$class, $file); + } + + if (false === $file) { + // Remember that this class does not exist. + $this->missingClasses[$class] = true; + } + + return $file; + } + + /** + * Returns the currently registered loaders keyed by their corresponding vendor directories. + * + * @return array + */ + public static function getRegisteredLoaders() + { + return self::$registeredLoaders; + } + + /** + * @param string $class + * @param string $ext + * @return string|false + */ + private function findFileWithExtension($class, $ext) + { + // PSR-4 lookup + $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext; + + $first = $class[0]; + if (isset($this->prefixLengthsPsr4[$first])) { + $subPath = $class; + while (false !== $lastPos = strrpos($subPath, '\\')) { + $subPath = substr($subPath, 0, $lastPos); + $search = $subPath . '\\'; + if (isset($this->prefixDirsPsr4[$search])) { + $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1); + foreach ($this->prefixDirsPsr4[$search] as $dir) { + if (file_exists($file = $dir . $pathEnd)) { + return $file; + } + } + } + } + } + + // PSR-4 fallback dirs + foreach ($this->fallbackDirsPsr4 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) { + return $file; + } + } + + // PSR-0 lookup + if (false !== $pos = strrpos($class, '\\')) { + // namespaced class name + $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1) + . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR); + } else { + // PEAR-like class name + $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext; + } + + if (isset($this->prefixesPsr0[$first])) { + foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) { + if (0 === strpos($class, $prefix)) { + foreach ($dirs as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + } + } + } + + // PSR-0 fallback dirs + foreach ($this->fallbackDirsPsr0 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + + // PSR-0 include paths. + if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) { + return $file; + } + + return false; + } + + /** + * @return void + */ + private static function initializeIncludeClosure() + { + if (self::$includeFile !== null) { + return; + } + + /** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + * + * @param string $file + * @return void + */ + self::$includeFile = \Closure::bind(static function($file) { + include $file; + }, null, null); + } +} diff --git a/apps/sharing/composer/composer/InstalledVersions.php b/apps/sharing/composer/composer/InstalledVersions.php new file mode 100644 index 0000000000000..2052022fd8e1e --- /dev/null +++ b/apps/sharing/composer/composer/InstalledVersions.php @@ -0,0 +1,396 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer; + +use Composer\Autoload\ClassLoader; +use Composer\Semver\VersionParser; + +/** + * This class is copied in every Composer installed project and available to all + * + * See also https://getcomposer.org/doc/07-runtime.md#installed-versions + * + * To require its presence, you can require `composer-runtime-api ^2.0` + * + * @final + */ +class InstalledVersions +{ + /** + * @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to + * @internal + */ + private static $selfDir = null; + + /** + * @var mixed[]|null + * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array}|array{}|null + */ + private static $installed; + + /** + * @var bool + */ + private static $installedIsLocalDir; + + /** + * @var bool|null + */ + private static $canGetVendors; + + /** + * @var array[] + * @psalm-var array}> + */ + private static $installedByVendor = array(); + + /** + * Returns a list of all package names which are present, either by being installed, replaced or provided + * + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackages() + { + $packages = array(); + foreach (self::getInstalled() as $installed) { + $packages[] = array_keys($installed['versions']); + } + + if (1 === \count($packages)) { + return $packages[0]; + } + + return array_keys(array_flip(\call_user_func_array('array_merge', $packages))); + } + + /** + * Returns a list of all package names with a specific type e.g. 'library' + * + * @param string $type + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackagesByType($type) + { + $packagesByType = array(); + + foreach (self::getInstalled() as $installed) { + foreach ($installed['versions'] as $name => $package) { + if (isset($package['type']) && $package['type'] === $type) { + $packagesByType[] = $name; + } + } + } + + return $packagesByType; + } + + /** + * Checks whether the given package is installed + * + * This also returns true if the package name is provided or replaced by another package + * + * @param string $packageName + * @param bool $includeDevRequirements + * @return bool + */ + public static function isInstalled($packageName, $includeDevRequirements = true) + { + foreach (self::getInstalled() as $installed) { + if (isset($installed['versions'][$packageName])) { + return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false; + } + } + + return false; + } + + /** + * Checks whether the given package satisfies a version constraint + * + * e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call: + * + * Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3') + * + * @param VersionParser $parser Install composer/semver to have access to this class and functionality + * @param string $packageName + * @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package + * @return bool + */ + public static function satisfies(VersionParser $parser, $packageName, $constraint) + { + $constraint = $parser->parseConstraints((string) $constraint); + $provided = $parser->parseConstraints(self::getVersionRanges($packageName)); + + return $provided->matches($constraint); + } + + /** + * Returns a version constraint representing all the range(s) which are installed for a given package + * + * It is easier to use this via isInstalled() with the $constraint argument if you need to check + * whether a given version of a package is installed, and not just whether it exists + * + * @param string $packageName + * @return string Version constraint usable with composer/semver + */ + public static function getVersionRanges($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + $ranges = array(); + if (isset($installed['versions'][$packageName]['pretty_version'])) { + $ranges[] = $installed['versions'][$packageName]['pretty_version']; + } + if (array_key_exists('aliases', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']); + } + if (array_key_exists('replaced', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']); + } + if (array_key_exists('provided', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']); + } + + return implode(' || ', $ranges); + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['version'])) { + return null; + } + + return $installed['versions'][$packageName]['version']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getPrettyVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['pretty_version'])) { + return null; + } + + return $installed['versions'][$packageName]['pretty_version']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference + */ + public static function getReference($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['reference'])) { + return null; + } + + return $installed['versions'][$packageName]['reference']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path. + */ + public static function getInstallPath($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @return array + * @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool} + */ + public static function getRootPackage() + { + $installed = self::getInstalled(); + + return $installed[0]['root']; + } + + /** + * Returns the raw installed.php data for custom implementations + * + * @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect. + * @return array[] + * @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} + */ + public static function getRawData() + { + @trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED); + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + self::$installed = include __DIR__ . '/installed.php'; + } else { + self::$installed = array(); + } + } + + return self::$installed; + } + + /** + * Returns the raw data of all installed.php which are currently loaded for custom implementations + * + * @return array[] + * @psalm-return list}> + */ + public static function getAllRawData() + { + return self::getInstalled(); + } + + /** + * Lets you reload the static array from another file + * + * This is only useful for complex integrations in which a project needs to use + * this class but then also needs to execute another project's autoloader in process, + * and wants to ensure both projects have access to their version of installed.php. + * + * A typical case would be PHPUnit, where it would need to make sure it reads all + * the data it needs from this class, then call reload() with + * `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure + * the project in which it runs can then also use this class safely, without + * interference between PHPUnit's dependencies and the project's dependencies. + * + * @param array[] $data A vendor/composer/installed.php data set + * @return void + * + * @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $data + */ + public static function reload($data) + { + self::$installed = $data; + self::$installedByVendor = array(); + + // when using reload, we disable the duplicate protection to ensure that self::$installed data is + // always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not, + // so we have to assume it does not, and that may result in duplicate data being returned when listing + // all installed packages for example + self::$installedIsLocalDir = false; + } + + /** + * @return string + */ + private static function getSelfDir() + { + if (self::$selfDir === null) { + self::$selfDir = strtr(__DIR__, '\\', '/'); + } + + return self::$selfDir; + } + + /** + * @return array[] + * @psalm-return list}> + */ + private static function getInstalled() + { + if (null === self::$canGetVendors) { + self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders'); + } + + $installed = array(); + $copiedLocalDir = false; + + if (self::$canGetVendors) { + $selfDir = self::getSelfDir(); + foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) { + $vendorDir = strtr($vendorDir, '\\', '/'); + if (isset(self::$installedByVendor[$vendorDir])) { + $installed[] = self::$installedByVendor[$vendorDir]; + } elseif (is_file($vendorDir.'/composer/installed.php')) { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require $vendorDir.'/composer/installed.php'; + self::$installedByVendor[$vendorDir] = $required; + $installed[] = $required; + if (self::$installed === null && $vendorDir.'/composer' === $selfDir) { + self::$installed = $required; + self::$installedIsLocalDir = true; + } + } + if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) { + $copiedLocalDir = true; + } + } + } + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require __DIR__ . '/installed.php'; + self::$installed = $required; + } else { + self::$installed = array(); + } + } + + if (self::$installed !== array() && !$copiedLocalDir) { + $installed[] = self::$installed; + } + + return $installed; + } +} diff --git a/apps/sharing/composer/composer/LICENSE b/apps/sharing/composer/composer/LICENSE new file mode 100644 index 0000000000000..f27399a042d95 --- /dev/null +++ b/apps/sharing/composer/composer/LICENSE @@ -0,0 +1,21 @@ + +Copyright (c) Nils Adermann, Jordi Boggiano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/apps/sharing/composer/composer/autoload_classmap.php b/apps/sharing/composer/composer/autoload_classmap.php new file mode 100644 index 0000000000000..163ae995691ba --- /dev/null +++ b/apps/sharing/composer/composer/autoload_classmap.php @@ -0,0 +1,31 @@ + $vendorDir . '/composer/InstalledVersions.php', + 'OCA\\Sharing\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php', + 'OCA\\Sharing\\Capabilities' => $baseDir . '/../lib/Capabilities.php', + 'OCA\\Sharing\\Command\\AddShareRecipient' => $baseDir . '/../lib/Command/AddShareRecipient.php', + 'OCA\\Sharing\\Command\\AddShareSource' => $baseDir . '/../lib/Command/AddShareSource.php', + 'OCA\\Sharing\\Command\\CreateShare' => $baseDir . '/../lib/Command/CreateShare.php', + 'OCA\\Sharing\\Command\\DeleteShare' => $baseDir . '/../lib/Command/DeleteShare.php', + 'OCA\\Sharing\\Command\\GetShare' => $baseDir . '/../lib/Command/GetShare.php', + 'OCA\\Sharing\\Command\\GetShares' => $baseDir . '/../lib/Command/GetShares.php', + 'OCA\\Sharing\\Command\\RemoveShareRecipient' => $baseDir . '/../lib/Command/RemoveShareRecipient.php', + 'OCA\\Sharing\\Command\\RemoveShareSource' => $baseDir . '/../lib/Command/RemoveShareSource.php', + 'OCA\\Sharing\\Command\\SelectSharePermissionPreset' => $baseDir . '/../lib/Command/SelectSharePermissionPreset.php', + 'OCA\\Sharing\\Command\\SharingBase' => $baseDir . '/../lib/Command/SharingBase.php', + 'OCA\\Sharing\\Command\\UpdateSharePermission' => $baseDir . '/../lib/Command/UpdateSharePermission.php', + 'OCA\\Sharing\\Command\\UpdateShareProperty' => $baseDir . '/../lib/Command/UpdateShareProperty.php', + 'OCA\\Sharing\\Command\\UpdateShareRecipientSecret' => $baseDir . '/../lib/Command/UpdateShareRecipientSecret.php', + 'OCA\\Sharing\\Command\\UpdateShareState' => $baseDir . '/../lib/Command/UpdateShareState.php', + 'OCA\\Sharing\\Controller\\ApiV1Controller' => $baseDir . '/../lib/Controller/ApiV1Controller.php', + 'OCA\\Sharing\\Middleware\\ShareApiEnabledMiddleware' => $baseDir . '/../lib/Middleware/ShareApiEnabledMiddleware.php', + 'OCA\\Sharing\\Migration\\Version1000Date20250929161325' => $baseDir . '/../lib/Migration/Version1000Date20250929161325.php', + 'OCA\\Sharing\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php', + 'OCA\\Sharing\\SharingBackend' => $baseDir . '/../lib/SharingBackend.php', +); diff --git a/apps/sharing/composer/composer/autoload_namespaces.php b/apps/sharing/composer/composer/autoload_namespaces.php new file mode 100644 index 0000000000000..3f5c929625125 --- /dev/null +++ b/apps/sharing/composer/composer/autoload_namespaces.php @@ -0,0 +1,9 @@ + array($baseDir . '/../lib'), +); diff --git a/apps/sharing/composer/composer/autoload_real.php b/apps/sharing/composer/composer/autoload_real.php new file mode 100644 index 0000000000000..5e91bbd37b802 --- /dev/null +++ b/apps/sharing/composer/composer/autoload_real.php @@ -0,0 +1,37 @@ +setClassMapAuthoritative(true); + $loader->register(true); + + return $loader; + } +} diff --git a/apps/sharing/composer/composer/autoload_static.php b/apps/sharing/composer/composer/autoload_static.php new file mode 100644 index 0000000000000..00ae4e33b5d40 --- /dev/null +++ b/apps/sharing/composer/composer/autoload_static.php @@ -0,0 +1,57 @@ + + array ( + 'OCA\\Sharing\\' => 12, + ), + ); + + public static $prefixDirsPsr4 = array ( + 'OCA\\Sharing\\' => + array ( + 0 => __DIR__ . '/..' . '/../lib', + ), + ); + + public static $classMap = array ( + 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', + 'OCA\\Sharing\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php', + 'OCA\\Sharing\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php', + 'OCA\\Sharing\\Command\\AddShareRecipient' => __DIR__ . '/..' . '/../lib/Command/AddShareRecipient.php', + 'OCA\\Sharing\\Command\\AddShareSource' => __DIR__ . '/..' . '/../lib/Command/AddShareSource.php', + 'OCA\\Sharing\\Command\\CreateShare' => __DIR__ . '/..' . '/../lib/Command/CreateShare.php', + 'OCA\\Sharing\\Command\\DeleteShare' => __DIR__ . '/..' . '/../lib/Command/DeleteShare.php', + 'OCA\\Sharing\\Command\\GetShare' => __DIR__ . '/..' . '/../lib/Command/GetShare.php', + 'OCA\\Sharing\\Command\\GetShares' => __DIR__ . '/..' . '/../lib/Command/GetShares.php', + 'OCA\\Sharing\\Command\\RemoveShareRecipient' => __DIR__ . '/..' . '/../lib/Command/RemoveShareRecipient.php', + 'OCA\\Sharing\\Command\\RemoveShareSource' => __DIR__ . '/..' . '/../lib/Command/RemoveShareSource.php', + 'OCA\\Sharing\\Command\\SelectSharePermissionPreset' => __DIR__ . '/..' . '/../lib/Command/SelectSharePermissionPreset.php', + 'OCA\\Sharing\\Command\\SharingBase' => __DIR__ . '/..' . '/../lib/Command/SharingBase.php', + 'OCA\\Sharing\\Command\\UpdateSharePermission' => __DIR__ . '/..' . '/../lib/Command/UpdateSharePermission.php', + 'OCA\\Sharing\\Command\\UpdateShareProperty' => __DIR__ . '/..' . '/../lib/Command/UpdateShareProperty.php', + 'OCA\\Sharing\\Command\\UpdateShareRecipientSecret' => __DIR__ . '/..' . '/../lib/Command/UpdateShareRecipientSecret.php', + 'OCA\\Sharing\\Command\\UpdateShareState' => __DIR__ . '/..' . '/../lib/Command/UpdateShareState.php', + 'OCA\\Sharing\\Controller\\ApiV1Controller' => __DIR__ . '/..' . '/../lib/Controller/ApiV1Controller.php', + 'OCA\\Sharing\\Middleware\\ShareApiEnabledMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/ShareApiEnabledMiddleware.php', + 'OCA\\Sharing\\Migration\\Version1000Date20250929161325' => __DIR__ . '/..' . '/../lib/Migration/Version1000Date20250929161325.php', + 'OCA\\Sharing\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php', + 'OCA\\Sharing\\SharingBackend' => __DIR__ . '/..' . '/../lib/SharingBackend.php', + ); + + public static function getInitializer(ClassLoader $loader) + { + return \Closure::bind(function () use ($loader) { + $loader->prefixLengthsPsr4 = ComposerStaticInitSharing::$prefixLengthsPsr4; + $loader->prefixDirsPsr4 = ComposerStaticInitSharing::$prefixDirsPsr4; + $loader->classMap = ComposerStaticInitSharing::$classMap; + + }, null, ClassLoader::class); + } +} diff --git a/apps/sharing/composer/composer/installed.json b/apps/sharing/composer/composer/installed.json new file mode 100644 index 0000000000000..f20a6c47c6d4f --- /dev/null +++ b/apps/sharing/composer/composer/installed.json @@ -0,0 +1,5 @@ +{ + "packages": [], + "dev": false, + "dev-package-names": [] +} diff --git a/apps/sharing/composer/composer/installed.php b/apps/sharing/composer/composer/installed.php new file mode 100644 index 0000000000000..82a41587de859 --- /dev/null +++ b/apps/sharing/composer/composer/installed.php @@ -0,0 +1,23 @@ + array( + 'name' => '__root__', + 'pretty_version' => 'dev-master', + 'version' => 'dev-master', + 'reference' => '80e46b06f38dbb04b413d427a75ef60eb5b29e18', + 'type' => 'library', + 'install_path' => __DIR__ . '/../', + 'aliases' => array(), + 'dev' => false, + ), + 'versions' => array( + '__root__' => array( + 'pretty_version' => 'dev-master', + 'version' => 'dev-master', + 'reference' => '80e46b06f38dbb04b413d427a75ef60eb5b29e18', + 'type' => 'library', + 'install_path' => __DIR__ . '/../', + 'aliases' => array(), + 'dev_requirement' => false, + ), + ), +); diff --git a/apps/sharing/lib/AppInfo/Application.php b/apps/sharing/lib/AppInfo/Application.php new file mode 100644 index 0000000000000..718a088b9e1c1 --- /dev/null +++ b/apps/sharing/lib/AppInfo/Application.php @@ -0,0 +1,42 @@ +registerCapability(Capabilities::class); + $context->registerMiddleware(ShareApiEnabledMiddleware::class); + + $registry = Server::get(ISharingRegistry::class); + $registry->registerSharingBackend(Server::get(SharingBackend::class)); + } + + #[\Override] + public function boot(IBootContext $context): void { + // Nothing to do + } +} diff --git a/apps/sharing/lib/Capabilities.php b/apps/sharing/lib/Capabilities.php new file mode 100644 index 0000000000000..a1d3f547e9d2e --- /dev/null +++ b/apps/sharing/lib/Capabilities.php @@ -0,0 +1,65 @@ +, + * source_types: list, + * permission_presets: list, + * }, + * } + */ + #[\Override] + public function getCapabilities(): array { + if (!Server::get(IManager::class)->shareApiEnabled()) { + return []; + } + + $sourceTypes = array_map(static fn (IShareSourceType $sourceType): array => [ + 'class' => $sourceType::class, + ], array_values($this->registry->getSourceTypes())); + + $permissionPresets = array_map(fn (ISharePermissionPreset $permissionPreset): array => [ + 'class' => $permissionPreset::class, + 'display_name' => $permissionPreset->getDisplayName($this->l10nFactory), + 'hint' => $permissionPreset->getHint($this->l10nFactory), + ], array_values($this->registry->getPermissionPresets())); + + return [ + Application::APP_ID => [ + 'api_versions' => ['v1'], + 'source_types' => $sourceTypes, + 'permission_presets' => $permissionPresets, + ], + ]; + } +} diff --git a/apps/sharing/lib/Command/AddShareRecipient.php b/apps/sharing/lib/Command/AddShareRecipient.php new file mode 100644 index 0000000000000..3990cf423f9e8 --- /dev/null +++ b/apps/sharing/lib/Command/AddShareRecipient.php @@ -0,0 +1,47 @@ +setName('sharing:add-share-recipient') + ->setDescription('Add a new recipient to a share.') + ->addArgument('id', InputArgument::REQUIRED, 'Share ID') + ->addArgument('class', InputArgument::REQUIRED, 'Recipient class') + ->addArgument('value', InputArgument::REQUIRED, 'Recipient value') + ->addArgument('instance', InputArgument::OPTIONAL, 'Recipient instance'); + } + + #[\Override] + public function execute(InputInterface $input, OutputInterface $output): int { + /** @var string $id */ + $id = $input->getArgument('id'); + /** @var class-string $class */ + $class = $input->getArgument('class'); + /** @var non-empty-string $value */ + $value = $input->getArgument('value'); + /** @var ?non-empty-string $instance */ + $instance = $input->getArgument('instance'); + + return $this->wrapExecution($output, function () use ($id, $class, $value, $instance): string { + $this->manager->addShareRecipient($this->accessContext, $id, new ShareRecipient($class, $value, $instance)); + return $id; + }); + } +} diff --git a/apps/sharing/lib/Command/AddShareSource.php b/apps/sharing/lib/Command/AddShareSource.php new file mode 100644 index 0000000000000..48126f90d12af --- /dev/null +++ b/apps/sharing/lib/Command/AddShareSource.php @@ -0,0 +1,43 @@ +setName('sharing:add-share-source') + ->setDescription('Add a new source to a share.') + ->addArgument('id', InputArgument::REQUIRED, 'Share ID') + ->addArgument('class', InputArgument::REQUIRED, 'Source class') + ->addArgument('value', InputArgument::REQUIRED, 'Source value'); + } + + #[\Override] + public function execute(InputInterface $input, OutputInterface $output): int { + /** @var string $id */ + $id = $input->getArgument('id'); + /** @var class-string $class */ + $class = $input->getArgument('class'); + /** @var non-empty-string $value */ + $value = $input->getArgument('value'); + + return $this->wrapExecution($output, function () use ($id, $class, $value): string { + $this->manager->addShareSource($this->accessContext, $id, new ShareSource($class, $value)); + return $id; + }); + } +} diff --git a/apps/sharing/lib/Command/CreateShare.php b/apps/sharing/lib/Command/CreateShare.php new file mode 100644 index 0000000000000..771d69976f0f7 --- /dev/null +++ b/apps/sharing/lib/Command/CreateShare.php @@ -0,0 +1,41 @@ +setName('sharing:create-share') + ->setDescription('Create a new share.') + ->addArgument('owner', InputArgument::REQUIRED, 'User ID of the owner'); + } + + #[\Override] + public function execute(InputInterface $input, OutputInterface $output): int { + /** @var string $ownerUid */ + $ownerUid = $input->getArgument('owner'); + $owner = Server::get(IUserManager::class)->get($ownerUid); + if ($owner === null) { + throw new ShareInvalidException('The owner does not exist: ' . $ownerUid, Server::get(IFactory::class)->get('sharing')->t('The owner does not exist: %s', [$ownerUid])); + } + + return $this->wrapExecution($output, fn (): string => $this->manager->createShare(new ShareAccessContext($owner))); + } +} diff --git a/apps/sharing/lib/Command/DeleteShare.php b/apps/sharing/lib/Command/DeleteShare.php new file mode 100644 index 0000000000000..86bfd286c3a54 --- /dev/null +++ b/apps/sharing/lib/Command/DeleteShare.php @@ -0,0 +1,54 @@ +setName('sharing:delete-share') + ->setDescription('Delete a share.') + ->addArgument('id', InputArgument::REQUIRED, 'Share ID'); + } + + #[\Override] + public function execute(InputInterface $input, OutputInterface $output): int { + /** @var string $id */ + $id = $input->getArgument('id'); + + try { + try { + $this->dbConnection->beginTransaction(); + + $this->manager->deleteShare($this->accessContext, $id); + $this->dbConnection->commit(); + return Base::SUCCESS; + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } catch (AShareException $aShareException) { + if ($output instanceof ConsoleOutputInterface) { + $output = $output->getErrorOutput(); + } + + $output->writeln($aShareException->getMessage()); + return Base::FAILURE; + } + } +} diff --git a/apps/sharing/lib/Command/GetShare.php b/apps/sharing/lib/Command/GetShare.php new file mode 100644 index 0000000000000..53f1555308880 --- /dev/null +++ b/apps/sharing/lib/Command/GetShare.php @@ -0,0 +1,32 @@ +setName('sharing:get-share') + ->setDescription('Get a share.') + ->addArgument('id', InputArgument::REQUIRED, 'Share ID'); + } + + #[\Override] + public function execute(InputInterface $input, OutputInterface $output): int { + /** @var string $id */ + $id = $input->getArgument('id'); + + return $this->wrapExecution($output, fn (): string => $id); + } +} diff --git a/apps/sharing/lib/Command/GetShares.php b/apps/sharing/lib/Command/GetShares.php new file mode 100644 index 0000000000000..828c00ae6d454 --- /dev/null +++ b/apps/sharing/lib/Command/GetShares.php @@ -0,0 +1,62 @@ +setName('sharing:get-shares') + ->setDescription('Get multiple shares.') + ->addOption('filter-source-type-class', '', InputOption::VALUE_REQUIRED, 'Source type class to filter by') + ->addOption('filter-source-type-value', '', InputOption::VALUE_REQUIRED, 'Source type value to filter by') + ->addOption('last-share-id', '', InputOption::VALUE_REQUIRED, 'Share ID to use as an offset') + ->addOption('limit', '', InputOption::VALUE_REQUIRED, 'Maximum number of shares to return'); + } + + #[\Override] + public function execute(InputInterface $input, OutputInterface $output): int { + /** @var ?class-string $filterSourceTypeClass */ + $filterSourceTypeClass = $input->getOption('filter-source-type-class'); + /** @var ?class-string $filterSourceTypeValue */ + $filterSourceTypeValue = $input->getOption('filter-source-type-value'); + /** @var ?string $lastShareID */ + $lastShareID = $input->getOption('last-share-id'); + /** @var ?string $limit */ + $limit = $input->getOption('limit'); + if ($limit !== null) { + $limit = (int)$limit; + if ($limit < 1) { + $output->writeln('The limit is too low.'); + return Base::FAILURE; + } + } + + try { + $this->dbConnection->beginTransaction(); + + $shares = $this->manager->getShares($this->accessContext, $filterSourceTypeClass, $filterSourceTypeValue, $lastShareID, $limit); + $this->dbConnection->commit(); + $output->writeln(json_encode(Share::formatMultiple($this->registry, $this->l10nFactory, $this->urlGenerator, $this->userManager, $shares), JSON_THROW_ON_ERROR)); + return Base::SUCCESS; + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } +} diff --git a/apps/sharing/lib/Command/RemoveShareRecipient.php b/apps/sharing/lib/Command/RemoveShareRecipient.php new file mode 100644 index 0000000000000..5a8cbb993091d --- /dev/null +++ b/apps/sharing/lib/Command/RemoveShareRecipient.php @@ -0,0 +1,46 @@ +setName('sharing:remove-share-recipient') + ->setDescription('Remove an existing recipient from a share.') + ->addArgument('id', InputArgument::REQUIRED, 'Share ID') + ->addArgument('class', InputArgument::REQUIRED, 'Recipient class') + ->addArgument('value', InputArgument::REQUIRED, 'Recipient value') + ->addArgument('instance', InputArgument::OPTIONAL, 'Recipient instance'); + } + + #[\Override] + public function execute(InputInterface $input, OutputInterface $output): int { + /** @var string $id */ + $id = $input->getArgument('id'); + /** @var class-string $class */ + $class = $input->getArgument('class'); + /** @var non-empty-string $value */ + $value = $input->getArgument('value'); + /** @var ?non-empty-string $instance */ + $instance = $input->getArgument('instance'); + + return $this->wrapExecution($output, function () use ($id, $class, $value, $instance): string { + $this->manager->removeShareRecipient($this->accessContext, $id, new ShareRecipient($class, $value, $instance)); + return $id; + }); + } +} diff --git a/apps/sharing/lib/Command/RemoveShareSource.php b/apps/sharing/lib/Command/RemoveShareSource.php new file mode 100644 index 0000000000000..b8cd2eb94f8c8 --- /dev/null +++ b/apps/sharing/lib/Command/RemoveShareSource.php @@ -0,0 +1,43 @@ +setName('sharing:remove-share-source') + ->setDescription('Remove an existing source from a share.') + ->addArgument('id', InputArgument::REQUIRED, 'Share ID') + ->addArgument('class', InputArgument::REQUIRED, 'Source class') + ->addArgument('value', InputArgument::REQUIRED, 'Source value'); + } + + #[\Override] + public function execute(InputInterface $input, OutputInterface $output): int { + /** @var string $id */ + $id = $input->getArgument('id'); + /** @var class-string $class */ + $class = $input->getArgument('class'); + /** @var non-empty-string $value */ + $value = $input->getArgument('value'); + + return $this->wrapExecution($output, function () use ($id, $class, $value): string { + $this->manager->removeShareSource($this->accessContext, $id, new ShareSource($class, $value)); + return $id; + }); + } +} diff --git a/apps/sharing/lib/Command/SelectSharePermissionPreset.php b/apps/sharing/lib/Command/SelectSharePermissionPreset.php new file mode 100644 index 0000000000000..af2b6cb4d2afd --- /dev/null +++ b/apps/sharing/lib/Command/SelectSharePermissionPreset.php @@ -0,0 +1,39 @@ +setName('sharing:select-share-permission-preset') + ->setDescription('Select a permission preset for a share.') + ->addArgument('id', InputArgument::REQUIRED, 'Share ID') + ->addArgument('permission-preset', InputArgument::REQUIRED, 'Permission preset'); + } + + #[\Override] + public function execute(InputInterface $input, OutputInterface $output): int { + /** @var string $id */ + $id = $input->getArgument('id'); + /** @var class-string $permissionPresetClass */ + $permissionPresetClass = $input->getArgument('permission-preset'); + + return $this->wrapExecution($output, function () use ($id, $permissionPresetClass): string { + $this->manager->selectSharePermissionPreset($this->accessContext, $id, $permissionPresetClass); + return $id; + }); + } +} diff --git a/apps/sharing/lib/Command/SharingBase.php b/apps/sharing/lib/Command/SharingBase.php new file mode 100644 index 0000000000000..c05fd0c6f43c3 --- /dev/null +++ b/apps/sharing/lib/Command/SharingBase.php @@ -0,0 +1,69 @@ +accessContext = new ShareAccessContext(overrideChecks: true); + } + + /** + * @param Closure():string $closure + */ + protected function wrapExecution(OutputInterface $output, Closure $closure): int { + + try { + try { + $this->dbConnection->beginTransaction(); + + $id = $closure(); + $share = $this->manager->getShare($this->accessContext, $id); + $this->dbConnection->commit(); + $output->writeln(json_encode($share->format($this->registry, $this->l10nFactory, $this->urlGenerator, $this->userManager), JSON_THROW_ON_ERROR)); + return Base::SUCCESS; + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } catch (AShareException $aShareException) { + if ($output instanceof ConsoleOutputInterface) { + $output = $output->getErrorOutput(); + } + + $output->writeln($aShareException->getHint()); + return Base::FAILURE; + } + } +} diff --git a/apps/sharing/lib/Command/UpdateSharePermission.php b/apps/sharing/lib/Command/UpdateSharePermission.php new file mode 100644 index 0000000000000..070b636fc9f58 --- /dev/null +++ b/apps/sharing/lib/Command/UpdateSharePermission.php @@ -0,0 +1,44 @@ +setName('sharing:update-share-permission') + ->setDescription('Update a permission of a share.') + ->addArgument('id', InputArgument::REQUIRED, 'Share ID') + ->addArgument('class', InputArgument::REQUIRED, 'Permission class') + ->addArgument('enabled', InputArgument::REQUIRED, 'Permission enabled. Only takes "true" or "false".'); + } + + #[\Override] + public function execute(InputInterface $input, OutputInterface $output): int { + /** @var string $id */ + $id = $input->getArgument('id'); + /** @var class-string $class */ + $class = $input->getArgument('class'); + /** @var string $enabled */ + $enabled = $input->getArgument('enabled'); + $enabled = $enabled === 'true'; + + return $this->wrapExecution($output, function () use ($id, $class, $enabled): string { + $this->manager->updateSharePermission($this->accessContext, $id, new SharePermission($class, $enabled)); + return $id; + }); + } +} diff --git a/apps/sharing/lib/Command/UpdateShareProperty.php b/apps/sharing/lib/Command/UpdateShareProperty.php new file mode 100644 index 0000000000000..b611e4de9a4d8 --- /dev/null +++ b/apps/sharing/lib/Command/UpdateShareProperty.php @@ -0,0 +1,43 @@ +setName('sharing:update-share-property') + ->setDescription('Update a property of a share.') + ->addArgument('id', InputArgument::REQUIRED, 'Share ID') + ->addArgument('class', InputArgument::REQUIRED, 'Property class') + ->addArgument('value', InputArgument::OPTIONAL, 'Property value. Omitting it will remove the value.'); + } + + #[\Override] + public function execute(InputInterface $input, OutputInterface $output): int { + /** @var string $id */ + $id = $input->getArgument('id'); + /** @var class-string $class */ + $class = $input->getArgument('class'); + /** @var ?string $value */ + $value = $input->getArgument('value'); + + return $this->wrapExecution($output, function () use ($id, $class, $value): string { + $this->manager->updateShareProperty($this->accessContext, $id, new ShareProperty($class, $value)); + return $id; + }); + } +} diff --git a/apps/sharing/lib/Command/UpdateShareRecipientSecret.php b/apps/sharing/lib/Command/UpdateShareRecipientSecret.php new file mode 100644 index 0000000000000..c424af7e18785 --- /dev/null +++ b/apps/sharing/lib/Command/UpdateShareRecipientSecret.php @@ -0,0 +1,49 @@ +setName('sharing:update-share-recipient-secret') + ->setDescription('Update the scecret of a recipient.') + ->addArgument('secret', InputArgument::REQUIRED, 'Recipient secret') + ->addArgument('id', InputArgument::REQUIRED, 'Share ID') + ->addArgument('class', InputArgument::REQUIRED, 'Recipient class') + ->addArgument('value', InputArgument::REQUIRED, 'Recipient value') + ->addArgument('instance', InputArgument::OPTIONAL, 'Recipient instance'); + } + + #[\Override] + public function execute(InputInterface $input, OutputInterface $output): int { + /** @var string $id */ + $id = $input->getArgument('id'); + /** @var class-string $class */ + $class = $input->getArgument('class'); + /** @var non-empty-string $value */ + $value = $input->getArgument('value'); + /** @var ?non-empty-string $instance */ + $instance = $input->getArgument('instance'); + /** @var non-empty-string $secret */ + $secret = $input->getArgument('secret'); + + return $this->wrapExecution($output, function () use ($id, $class, $value, $instance, $secret): string { + $this->manager->updateShareRecipientSecret($this->accessContext, $id, new ShareRecipient($class, $value, $instance), $secret); + return $id; + }); + } +} diff --git a/apps/sharing/lib/Command/UpdateShareState.php b/apps/sharing/lib/Command/UpdateShareState.php new file mode 100644 index 0000000000000..bb1578c52f8f0 --- /dev/null +++ b/apps/sharing/lib/Command/UpdateShareState.php @@ -0,0 +1,40 @@ +setName('sharing:update-share-state') + ->setDescription('Update the state of a share.') + ->addArgument('id', InputArgument::REQUIRED, 'Share ID') + ->addArgument('state', InputArgument::REQUIRED, 'State'); + } + + #[\Override] + public function execute(InputInterface $input, OutputInterface $output): int { + /** @var string $id */ + $id = $input->getArgument('id'); + /** @var string $state */ + $state = $input->getArgument('state'); + $state = ShareState::from($state); + + return $this->wrapExecution($output, function () use ($id, $state): string { + $this->manager->updateShareState($this->accessContext, $id, $state); + return $id; + }); + } +} diff --git a/apps/sharing/lib/Controller/ApiV1Controller.php b/apps/sharing/lib/Controller/ApiV1Controller.php new file mode 100644 index 0000000000000..11334aa04caba --- /dev/null +++ b/apps/sharing/lib/Controller/ApiV1Controller.php @@ -0,0 +1,586 @@ +accessContext = new ShareAccessContext($userSession->getUser()); + } + + /** + * Search for recpients that can be added to a share. + * + * @param ?list> $recipientTypeClasses Type class of recipients to filter by + * @param string $query The query to search for + * @param int<1, 100> $limit The maximum number of participants + * @param non-negative-int $offset The offset of the participants + * @return DataResponse, array{}>|DataResponse + * + * 200: Recipients returned + * 400: Invalid recipient search parameters + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/v1/recipients')] + public function searchRecipients(?array $recipientTypeClasses, string $query, int $limit = 10, int $offset = 0): DataResponse { + /** @psalm-suppress DocblockTypeContradiction */ + if ($limit < 1) { + return new DataResponse('The limit is too low.', Http::STATUS_BAD_REQUEST); + } + + /** @psalm-suppress DocblockTypeContradiction */ + if ($limit > 100) { + return new DataResponse('The limit is too high.', Http::STATUS_BAD_REQUEST); + } + + /** @psalm-suppress DocblockTypeContradiction */ + if ($offset < 0) { + return new DataResponse('The offset is too low.', Http::STATUS_BAD_REQUEST); + } + + try { + $recipients = $this->manager->searchRecipients($this->accessContext, $recipientTypeClasses, $query, $limit, $offset); + return new DataResponse(ShareRecipient::formatMultiple($this->registry, $this->l10nFactory, $this->urlGenerator, $this->userManager, $recipients)); + } catch (ShareInvalidException $shareInvalidException) { + return new DataResponse($shareInvalidException->getHint(), Http::STATUS_BAD_REQUEST); + } + } + + /** + * Generate a new secret. + * + * @return DataResponse + * + * 200: Generated secret returned + */ + #[PublicPage] + #[ApiRoute(verb: 'GET', url: '/api/v1/secret')] + public function generateSecret(): DataResponse { + return new DataResponse($this->manager->generateSecret()); + } + + /** + * Create a new share. + * + * @return DataResponse + * + * 201: Share created successfully + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/api/v1/share')] + public function createShare(): DataResponse { + try { + try { + $this->dbConnection->beginTransaction(); + + $id = $this->manager->createShare($this->accessContext); + + $share = $this->manager->getShare($this->accessContext, $id); + $this->dbConnection->commit(); + return new DataResponse($share->format($this->registry, $this->l10nFactory, $this->urlGenerator, $this->userManager), Http::STATUS_CREATED); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } catch (ShareNotFoundException $shareNotFoundException) { + throw new RuntimeException($shareNotFoundException->getHint(), $shareNotFoundException->getCode(), $shareNotFoundException); + } + } + + /** + * Update the state of a share. + * + * @param string $id ID of the share + * @param SharingState $state New state of the share + * @return DataResponse|DataResponse + * + * 200: Share state updated successfully + * 400: Invalid share state + * 403: Updating the share state is not allowed + * 404: Share not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/api/v1/share/{id}/state')] + public function updateShareState(string $id, string $state): DataResponse { + try { + $shareState = ShareState::from($state); + } catch (ValueError $valueError) { + return new DataResponse($valueError->getMessage(), Http::STATUS_BAD_REQUEST); + } + + try { + try { + $this->dbConnection->beginTransaction(); + + $this->manager->updateShareState($this->accessContext, $id, $shareState); + $share = $this->manager->getShare($this->accessContext, $id); + $this->dbConnection->commit(); + return new DataResponse($share->format($this->registry, $this->l10nFactory, $this->urlGenerator, $this->userManager)); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } catch (ShareOperationForbiddenException $shareOperationForbiddenException) { + return new DataResponse($shareOperationForbiddenException->getHint(), Http::STATUS_FORBIDDEN); + } catch (ShareNotFoundException $shareNotFoundException) { + return new DataResponse($shareNotFoundException->getHint(), Http::STATUS_NOT_FOUND); + } + } + + /** + * Add a new source to a share. + * + * @param string $id ID of the share + * @param class-string $class Type class of the source + * @param non-empty-string $value Value of the source + * @return DataResponse|DataResponse + * + * 200: Share source added successfully + * 400: Invalid share source + * 403: Adding the share source is not allowed + * 404: Share not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/api/v1/share/{id}/source')] + public function addShareSource(string $id, string $class, string $value): DataResponse { + try { + try { + $this->dbConnection->beginTransaction(); + + $this->manager->addShareSource($this->accessContext, $id, new ShareSource($class, $value)); + $share = $this->manager->getShare($this->accessContext, $id); + $this->dbConnection->commit(); + return new DataResponse($share->format($this->registry, $this->l10nFactory, $this->urlGenerator, $this->userManager)); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } catch (ShareInvalidException $shareInvalidException) { + return new DataResponse($shareInvalidException->getHint(), Http::STATUS_BAD_REQUEST); + } catch (ShareOperationForbiddenException $shareOperationForbiddenException) { + return new DataResponse($shareOperationForbiddenException->getHint(), Http::STATUS_FORBIDDEN); + } catch (ShareNotFoundException $shareNotFoundException) { + return new DataResponse($shareNotFoundException->getHint(), Http::STATUS_NOT_FOUND); + } + } + + /** + * Remove an existing source from a share. + * + * @param string $id ID of the share + * @param class-string $class Type class of the source + * @param non-empty-string $value Value of the source + * @return DataResponse|DataResponse + * + * 200: Share source removed successfully + * 403: Removing the share source is not allowed + * 404: Share not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/v1/share/{id}/source')] + public function removeShareSource(string $id, string $class, string $value): DataResponse { + try { + try { + $this->dbConnection->beginTransaction(); + + $this->manager->removeShareSource($this->accessContext, $id, new ShareSource($class, $value)); + $share = $this->manager->getShare($this->accessContext, $id); + $this->dbConnection->commit(); + return new DataResponse($share->format($this->registry, $this->l10nFactory, $this->urlGenerator, $this->userManager)); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } catch (ShareOperationForbiddenException $shareOperationForbiddenException) { + return new DataResponse($shareOperationForbiddenException->getHint(), Http::STATUS_FORBIDDEN); + } catch (ShareNotFoundException $shareNotFoundException) { + return new DataResponse($shareNotFoundException->getHint(), Http::STATUS_NOT_FOUND); + } + } + + /** + * Add a new recipient to a share. + * + * @param string $id ID of the share + * @param class-string $class Type class of the recipient + * @param non-empty-string $value Value of the recipient + * @param ?non-empty-string $instance Instance of the recipient + * @return DataResponse|DataResponse + * + * 200: Share recipient added successfully + * 400: Invalid share recipient + * 403: Adding the share recipient is not allowed + * 404: Share not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/api/v1/share/{id}/recipient')] + public function addShareRecipient(string $id, string $class, string $value, ?string $instance): DataResponse { + try { + try { + $this->dbConnection->beginTransaction(); + + $this->manager->addShareRecipient($this->accessContext, $id, new ShareRecipient($class, $value, $instance)); + $share = $this->manager->getShare($this->accessContext, $id); + $this->dbConnection->commit(); + return new DataResponse($share->format($this->registry, $this->l10nFactory, $this->urlGenerator, $this->userManager)); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } catch (ShareInvalidException $shareInvalidException) { + return new DataResponse($shareInvalidException->getHint(), Http::STATUS_BAD_REQUEST); + } catch (ShareOperationForbiddenException $shareOperationForbiddenException) { + return new DataResponse($shareOperationForbiddenException->getHint(), Http::STATUS_FORBIDDEN); + } catch (ShareNotFoundException $shareNotFoundException) { + return new DataResponse($shareNotFoundException->getHint(), Http::STATUS_NOT_FOUND); + } + } + + /** + * Remove an existing recipient from a share. + * + * @param string $id ID of the share + * @param class-string $class Type class of the recipient + * @param non-empty-string $value Value of the recipient + * @param ?non-empty-string $instance Instance of the recipient + * @return DataResponse|DataResponse + * + * 200: Share recipient removed successfully + * 403: Removing the share recipient is not allowed + * 404: Share not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/v1/share/{id}/recipient')] + public function removeShareRecipient(string $id, string $class, string $value, ?string $instance): DataResponse { + try { + try { + $this->dbConnection->beginTransaction(); + + $this->manager->removeShareRecipient($this->accessContext, $id, new ShareRecipient($class, $value, $instance)); + $share = $this->manager->getShare($this->accessContext, $id); + $this->dbConnection->commit(); + return new DataResponse($share->format($this->registry, $this->l10nFactory, $this->urlGenerator, $this->userManager)); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } catch (ShareOperationForbiddenException $shareOperationForbiddenException) { + return new DataResponse($shareOperationForbiddenException->getHint(), Http::STATUS_FORBIDDEN); + } catch (ShareNotFoundException $shareNotFoundException) { + return new DataResponse($shareNotFoundException->getHint(), Http::STATUS_NOT_FOUND); + } + } + + /** + * Update the scecret of a recipient. + * + * @param string $id ID of the share + * @param class-string $class Type class of the recipient + * @param non-empty-string $value Value of the recipient + * @param ?non-empty-string $instance Instance of the recipient + * @param non-empty-string $secret Secret of the recipient + * @return DataResponse|DataResponse + * + * 200: Share recipient secret updated successfully + * 400: Invalid secret + * 403: Updating the share recipient secret is not allowed + * 404: Share not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/api/v1/share/{id}/recipient/secret')] + public function updateShareRecipientSecret(string $id, string $class, string $value, ?string $instance, string $secret): DataResponse { + try { + try { + $this->dbConnection->beginTransaction(); + + $this->manager->updateShareRecipientSecret($this->accessContext, $id, new ShareRecipient($class, $value, $instance), $secret); + $share = $this->manager->getShare($this->accessContext, $id); + $this->dbConnection->commit(); + return new DataResponse($share->format($this->registry, $this->l10nFactory, $this->urlGenerator, $this->userManager)); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } catch (ShareInvalidException $shareInvalidException) { + return new DataResponse($shareInvalidException->getHint(), Http::STATUS_BAD_REQUEST); + } catch (ShareOperationForbiddenException $shareOperationForbiddenException) { + return new DataResponse($shareOperationForbiddenException->getHint(), Http::STATUS_FORBIDDEN); + } catch (ShareNotFoundException $shareNotFoundException) { + return new DataResponse($shareNotFoundException->getHint(), Http::STATUS_NOT_FOUND); + } + } + + /** + * Update a property of a share. + * + * @param string $id ID of the share + * @param class-string $class Type class of the property + * @param ?string $value Value of the property + * @return DataResponse|DataResponse + * + * 200: Share property updated successfully + * 400: Invalid share property + * 403: Updating the share property is not allowed + * 404: Share not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/api/v1/share/{id}/property')] + public function updateShareProperty(string $id, string $class, ?string $value): DataResponse { + try { + try { + $this->dbConnection->beginTransaction(); + + $this->manager->updateShareProperty($this->accessContext, $id, new ShareProperty($class, $value)); + $share = $this->manager->getShare($this->accessContext, $id); + $this->dbConnection->commit(); + return new DataResponse($share->format($this->registry, $this->l10nFactory, $this->urlGenerator, $this->userManager)); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } catch (ShareInvalidException $shareInvalidException) { + return new DataResponse($shareInvalidException->getHint(), Http::STATUS_BAD_REQUEST); + } catch (ShareOperationForbiddenException $shareOperationForbiddenException) { + return new DataResponse($shareOperationForbiddenException->getHint(), Http::STATUS_FORBIDDEN); + } catch (ShareNotFoundException $shareNotFoundException) { + return new DataResponse($shareNotFoundException->getHint(), Http::STATUS_NOT_FOUND); + } + } + + /** + * Update a permission of a share. + * + * @param string $id ID of the share + * @param class-string $class Type class of the permission + * @param bool $enabled Enabled state of the permission + * @return DataResponse|DataResponse + * + * 200: Share permission updated successfully + * 400: Invalid share permission + * 403: Updating the share permission is not allowed + * 404: Share not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/api/v1/share/{id}/permission')] + public function updateSharePermission(string $id, string $class, bool $enabled): DataResponse { + try { + try { + $this->dbConnection->beginTransaction(); + + $this->manager->updateSharePermission($this->accessContext, $id, new SharePermission($class, $enabled)); + $share = $this->manager->getShare($this->accessContext, $id); + $this->dbConnection->commit(); + return new DataResponse($share->format($this->registry, $this->l10nFactory, $this->urlGenerator, $this->userManager)); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } catch (ShareInvalidException $shareInvalidException) { + return new DataResponse($shareInvalidException->getHint(), Http::STATUS_BAD_REQUEST); + } catch (ShareOperationForbiddenException $shareOperationForbiddenException) { + return new DataResponse($shareOperationForbiddenException->getHint(), Http::STATUS_FORBIDDEN); + } catch (ShareNotFoundException $shareNotFoundException) { + return new DataResponse($shareNotFoundException->getHint(), Http::STATUS_NOT_FOUND); + } + } + + /** + * Select a permission preset for a share. + * + * @param string $id ID of the share + * @param class-string $permissionPresetClass New permission preset of the share + * @return DataResponse|DataResponse + * + * 200: Share permission preset selected successfully + * 400: Invalid share permission preset + * 403: Selecting the share permission preset is not allowed + * 404: Share not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/api/v1/share/{id}/permission/preset')] + public function selectSharePermissionPreset(string $id, string $permissionPresetClass): DataResponse { + try { + try { + $this->dbConnection->beginTransaction(); + + $this->manager->selectSharePermissionPreset($this->accessContext, $id, $permissionPresetClass); + $share = $this->manager->getShare($this->accessContext, $id); + $this->dbConnection->commit(); + return new DataResponse($share->format($this->registry, $this->l10nFactory, $this->urlGenerator, $this->userManager)); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } catch (ShareOperationForbiddenException $shareOperationForbiddenException) { + return new DataResponse($shareOperationForbiddenException->getHint(), Http::STATUS_FORBIDDEN); + } catch (ShareNotFoundException $shareNotFoundException) { + return new DataResponse($shareNotFoundException->getHint(), Http::STATUS_NOT_FOUND); + } + } + + /** + * Delete a share. + * + * @param string $id ID of the share + * @return DataResponse, array{}>|DataResponse + * + * 204: Share deleted + * 403: Deleting the share is not allowed + * 404: Share not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/v1/share/{id}')] + public function deleteShare(string $id): DataResponse { + try { + try { + $this->dbConnection->beginTransaction(); + + $this->manager->deleteShare($this->accessContext, $id); + $this->dbConnection->commit(); + return new DataResponse([], Http::STATUS_NO_CONTENT); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } catch (ShareNotFoundException $shareNotFoundException) { + return new DataResponse($shareNotFoundException->getHint(), Http::STATUS_NOT_FOUND); + } catch (ShareOperationForbiddenException $shareOperationNotAllowedException) { + return new DataResponse($shareOperationNotAllowedException->getHint(), Http::STATUS_FORBIDDEN); + } + } + + /** + * Get a share. + * + * @param string $id ID of the share + * @param ?string $secret Secret of the share + * @param array, mixed> $arguments Arguments for accessing the share + * @return DataResponse|DataResponse + * + * 200: Share returned + * 400: Invalid arguments + * 404: Share not found + */ + #[PublicPage] + // This should be a GET, but GET doesn't allow a request body which is required for the $arguments. + #[ApiRoute(verb: 'POST', url: '/api/v1/share/{id}')] + public function getShare(string $id, ?string $secret = null, array $arguments = []): DataResponse { + try { + try { + $this->dbConnection->beginTransaction(); + + $share = $this->manager->getShare(new ShareAccessContext($this->accessContext->currentUser, $secret, $arguments, $this->accessContext->overrideChecks), $id); + $this->dbConnection->commit(); + return new DataResponse($share->format($this->registry, $this->l10nFactory, $this->urlGenerator, $this->userManager)); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } catch (ShareInvalidException $shareInvalidException) { + return new DataResponse($shareInvalidException->getHint(), Http::STATUS_BAD_REQUEST); + } catch (ShareNotFoundException $shareNotFoundException) { + return new DataResponse($shareNotFoundException->getHint(), Http::STATUS_NOT_FOUND); + } + } + + /** + * Get multiple shares. + * + * @param ?class-string $filterSourceTypeClass Source type class to filter by. + * @param ?string $filterSourceTypeValue Source type value to filter by. + * @param ?string $lastShareID The ID of the previous share. This is used as an offset and only shares with higher IDs are returned. + * @param int<1, 100> $limit The number of shares to return. + * @return DataResponse, array{}>|DataResponse + * + * 200: Shares returned + * 400: Invalid parameters + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/v1/shares')] + public function getShares(?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?string $lastShareID, int $limit = 100): DataResponse { + /** @psalm-suppress DocblockTypeContradiction */ + if ($limit < 1) { + return new DataResponse('The limit is too low.', Http::STATUS_BAD_REQUEST); + } + + /** @psalm-suppress DocblockTypeContradiction */ + if ($limit > 100) { + return new DataResponse('The limit is too high.', Http::STATUS_BAD_REQUEST); + } + + try { + $this->dbConnection->beginTransaction(); + + $shares = $this->manager->getShares($this->accessContext, $filterSourceTypeClass, $filterSourceTypeValue, $lastShareID, $limit); + $this->dbConnection->commit(); + return new DataResponse(Share::formatMultiple($this->registry, $this->l10nFactory, $this->urlGenerator, $this->userManager, $shares)); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } +} diff --git a/apps/sharing/lib/Middleware/ShareApiEnabledMiddleware.php b/apps/sharing/lib/Middleware/ShareApiEnabledMiddleware.php new file mode 100644 index 0000000000000..cbf6c212048f8 --- /dev/null +++ b/apps/sharing/lib/Middleware/ShareApiEnabledMiddleware.php @@ -0,0 +1,26 @@ +shareApiEnabled()) { + throw new OCSException('The Share API is not enabled.', Http::STATUS_NOT_IMPLEMENTED); + } + } +} diff --git a/apps/sharing/lib/Migration/Version1000Date20250929161325.php b/apps/sharing/lib/Migration/Version1000Date20250929161325.php new file mode 100644 index 0000000000000..1cea07e223102 --- /dev/null +++ b/apps/sharing/lib/Migration/Version1000Date20250929161325.php @@ -0,0 +1,77 @@ +createTable('sharing_share'); + $shareTable->addColumn('id', Types::BIGINT); + $shareTable->addColumn('owner_user_id', Types::STRING, ['length' => 64]); + $shareTable->addColumn('owner_instance', Types::STRING, ['length' => 128, 'notnull' => false]); + $shareTable->addColumn('last_updated', Types::BIGINT); + $shareTable->addColumn('state', Types::STRING, ['length' => 16]); + $shareTable->setPrimaryKey(['id']); + + $sourcesTable = $schema->createTable('sharing_share_sources'); + $sourcesTable->addColumn('share_id', Types::BIGINT); + $sourcesTable->addColumn('source_class', Types::STRING, ['length' => 64]); + $sourcesTable->addColumn('source_value', Types::STRING, ['length' => 255]); + $sourcesTable->setPrimaryKey(['share_id', 'source_class', 'source_value']); + $sourcesTable->addForeignKeyConstraint($shareTable->getName(), ['share_id'], ['id'], ['onDelete' => 'CASCADE']); + + // TODO: Add possibility to mask permissions for recipients. For reshares the user may only mask permissions for their child recipients, not their self recipients + $recipientsTable = $schema->createTable('sharing_share_recipients'); + $recipientsTable->addColumn('share_id', Types::BIGINT); + $recipientsTable->addColumn('recipient_class', Types::STRING, ['length' => 64]); + $recipientsTable->addColumn('recipient_value', Types::STRING, ['length' => 255]); + $recipientsTable->addColumn('recipient_instance', Types::STRING, ['length' => 128, 'notnull' => false]); + $recipientsTable->addColumn('recipient_secret', Types::STRING, ['length' => 32]); + $recipientsTable->addColumn('initiator_user_id', Types::STRING, ['length' => 64]); + $recipientsTable->addColumn('initiator_instance', Types::STRING, ['length' => 128, 'notnull' => false]); + $recipientsTable->setPrimaryKey(['share_id', 'recipient_class', 'recipient_value']); + $recipientsTable->addForeignKeyConstraint($shareTable->getName(), ['share_id'], ['id'], ['onDelete' => 'CASCADE']); + // TODO: Maybe needs composite index with share_id + $recipientsTable->addUniqueIndex(['recipient_secret']); + + $propertiesTable = $schema->createTable('sharing_share_properties'); + $propertiesTable->addColumn('share_id', Types::BIGINT); + $propertiesTable->addColumn('property_class', Types::STRING, ['length' => 64]); + $propertiesTable->addColumn('property_value', Types::STRING, ['length' => 1000, 'notnull' => false]); + $propertiesTable->setPrimaryKey(['share_id', 'property_class']); + $propertiesTable->addForeignKeyConstraint($shareTable->getName(), ['share_id'], ['id'], ['onDelete' => 'CASCADE']); + + $permissionsTable = $schema->createTable('sharing_share_permissions'); + $permissionsTable->addColumn('share_id', Types::BIGINT); + $permissionsTable->addColumn('permission_class', Types::STRING, ['length' => 64]); + $permissionsTable->addColumn('permission_enabled', Types::BOOLEAN); + $permissionsTable->setPrimaryKey(['share_id', 'permission_class']); + $permissionsTable->addForeignKeyConstraint($shareTable->getName(), ['share_id'], ['id'], ['onDelete' => 'CASCADE']); + + return $schema; + } +} diff --git a/apps/sharing/lib/ResponseDefinitions.php b/apps/sharing/lib/ResponseDefinitions.php new file mode 100644 index 0000000000000..8765fa0c0a61e --- /dev/null +++ b/apps/sharing/lib/ResponseDefinitions.php @@ -0,0 +1,136 @@ +, + * value: non-empty-string, + * display_name: non-empty-string, + * icon: ?SharingIcon, + * } + * + * @psalm-type SharingUser = array{ + * user_id: non-empty-string, + * instance: ?non-empty-string, + * display_name: non-empty-string, + * icon: SharingIcon, + * } + * + * @psalm-type SharingRecipient = array{ + * class: class-string, + * value: non-empty-string, + * instance: ?non-empty-string, + * display_name: non-empty-string, + * icon: ?SharingIcon, + * secret: array{ + * updatable: bool, + * value?: non-empty-string, + * url?: non-empty-string, + * }, + * initiator: ?SharingUser, + * } + * + * @psalm-type SharingState = 'active'|'draft'|'deleted' + * + * @psalm-type SharingProperty = array{ + * class: class-string, + * display_name: non-empty-string, + * hint: ?non-empty-string, + * priority: int<1, 100>, + * required: bool, + * advanced: bool, + * value: ?string, + * } + * + * @psalm-type SharingPropertyBoolean = SharingProperty&array{ + * type: 'boolean', + * } + * + * @psalm-type SharingPropertyDate = SharingProperty&array{ + * type: 'date', + * // ISO 8601 + * min_date: ?non-empty-string, + * // ISO 8601 + * max_date: ?non-empty-string, + * } + * + * @psalm-type SharingPropertyEnum = SharingProperty&array{ + * type: 'enum', + * valid_values: non-empty-list, + * } + * + * @psalm-type SharingPropertyPassword = SharingProperty&array{ + * type: 'password', + * } + * + * @psalm-type SharingPropertyString = SharingProperty&array{ + * type: 'string', + * min_length: ?positive-int, + * max_length: ?positive-int, + * } + * + * @psalm-type SharingPermissionPreset = array{ + * class: class-string, + * display_name: non-empty-string, + * hint: ?non-empty-string, + * } + * + * @psalm-type SharingPermission = array{ + * class: class-string, + * source_class: ?class-string, + * display_name: non-empty-string, + * hint: ?non-empty-string, + * priority: int<1, 100>, + * presets: list>, + * enabled: bool, + * } + * + * @psalm-type SharingSourceType = array{ + * class: class-string, + * } + * + * @psalm-type SharingShare = array{ + * id: non-empty-string, + * owner: SharingUser, + * // Unix time in milliseconds + * last_updated: non-negative-int, + * state: SharingState, + * sources: list, + * recipients: list, + * properties: list, + * permissions: list, + * permission_preset: ?class-string, + * } + */ +final class ResponseDefinitions { +} diff --git a/apps/sharing/lib/SharingBackend.php b/apps/sharing/lib/SharingBackend.php new file mode 100644 index 0000000000000..2acd02420af64 --- /dev/null +++ b/apps/sharing/lib/SharingBackend.php @@ -0,0 +1,953 @@ +l10n = $factory->get('sharing'); + } + + #[\Override] + public function createShare(IUser $owner): string { + $id = $this->snowflakeGenerator->nextId(); + $lastUpdated = $this->manager->generateTimestamp(); + + $qb = $this->connection->getQueryBuilder(); + $qb + ->insert('sharing_share') + ->values([ + 'id' => $qb->createNamedParameter($id), + 'owner_user_id' => $qb->createNamedParameter($owner->getUID()), + 'last_updated' => $qb->createNamedParameter($lastUpdated), + 'state' => $qb->createNamedParameter(ShareState::Draft->value), + ]) + ->executeStatement(); + + return $id; + } + + #[\Override] + public function onOwnerDeleted(IUser $owner): void { + $qb = $this->connection->getQueryBuilder(); + $qb + ->delete('sharing_share') + ->where($qb->expr()->eq('owner_user_id', $qb->createNamedParameter($owner->getUID()))) + ->andWhere($qb->expr()->isNull('owner_instance')) + ->executeStatement(); + } + + #[\Override] + public function updateShareState(string $id, ShareState $state): void { + $qb = $this->connection->getQueryBuilder(); + $rowCount = $qb + ->update('sharing_share') + ->set('state', $qb->createNamedParameter($state->value)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))) + ->executeStatement(); + if ($rowCount === 0) { + throw new ShareNotFoundException(); + } + } + + #[\Override] + public function addShareSource(string $id, ShareSource $source): void { + try { + $qb = $this->connection->getQueryBuilder(); + $qb + ->insert('sharing_share_sources') + ->values([ + 'share_id' => $qb->createNamedParameter($id), + 'source_class' => $qb->createNamedParameter($source->class), + 'source_value' => $qb->createNamedParameter($source->value), + ]) + ->executeStatement(); + } catch (Exception $exception) { + if ($exception instanceof \OCP\DB\Exception && $exception->getReason() === \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw new ShareInvalidException('Tried to add share source that already exists: ' . $source->class . ' ' . $source->value, $this->l10n->t('The share already contains the source.'), previous: $exception); + } + + throw $exception; + } + } + + #[\Override] + public function removeShareSource(string $id, ShareSource $source): void { + $qb = $this->connection->getQueryBuilder(); + $rowCount = $qb + ->delete('sharing_share_sources') + ->where($qb->expr()->eq('share_id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->eq('source_class', $qb->createNamedParameter($source->class))) + ->andWhere($qb->expr()->eq('source_value', $qb->createNamedParameter($source->value))) + ->executeStatement(); + if ($rowCount === 0) { + throw new ShareNotFoundException(); + } + } + + #[\Override] + public function onSourceDeleted(ShareSource $source): array { + $qb = $this->connection->getQueryBuilder(); + $result = $qb + ->selectDistinct('share_id') + ->from('sharing_share_sources') + ->where($qb->expr()->eq('source_class', $qb->createNamedParameter($source->class))) + ->andWhere($qb->expr()->eq('source_value', $qb->createNamedParameter($source->value))) + ->executeQuery(); + + /** @var list $ids */ + $ids = $result->fetchFirstColumn(); + if ($ids === []) { + return []; + } + + $ids = array_map(static fn (string|int $id): string => (string)$id, $ids); + + $qb = $this->connection->getQueryBuilder(); + $qb + ->delete('sharing_share_sources') + ->where($qb->expr()->eq('source_class', $qb->createNamedParameter($source->class))) + ->andWhere($qb->expr()->eq('source_value', $qb->createNamedParameter($source->value))) + ->executeStatement(); + + return $ids; + } + + #[\Override] + public function addShareRecipient(string $id, IUser $initiator, ShareRecipient $recipient): void { + try { + $qb = $this->connection->getQueryBuilder(); + + $values = [ + 'share_id' => $qb->createNamedParameter($id), + 'recipient_class' => $qb->createNamedParameter($recipient->class), + 'recipient_value' => $qb->createNamedParameter($recipient->value), + 'recipient_instance' => $qb->createNamedParameter($recipient->instance), + 'recipient_secret' => $qb->createNamedParameter($this->manager->generateSecret()), + 'initiator_user_id' => $qb->createNamedParameter($initiator->getUID(), IQueryBuilder::PARAM_STR), + ]; + + $qb + ->insert('sharing_share_recipients') + ->values($values) + ->executeStatement(); + } catch (Exception $exception) { + if ($exception instanceof \OCP\DB\Exception && $exception->getReason() === \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw new ShareInvalidException('Tried to add share recipient that already exists: ' . $recipient->class . ' ' . $recipient->value . ' ' . ($recipient->instance ?? 'local'), $this->l10n->t('The share already contains the recipient.'), previous: $exception); + } + + throw $exception; + } + } + + #[\Override] + public function removeShareRecipient(string $id, ShareRecipient $recipient): void { + $qb = $this->connection->getQueryBuilder(); + $rowCount = $qb + ->delete('sharing_share_recipients') + ->where($qb->expr()->eq('share_id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->eq('recipient_class', $qb->createNamedParameter($recipient->class))) + ->andWhere($qb->expr()->eq('recipient_value', $qb->createNamedParameter($recipient->value))) + ->andWhere( + $recipient->instance === null + ? $qb->expr()->isNull('recipient_instance') + : $qb->expr()->eq('recipient_instance', $qb->createNamedParameter($recipient->instance)) + ) + ->executeStatement(); + if ($rowCount === 0) { + throw new ShareNotFoundException(); + } + } + + #[\Override] + public function onRecipientDeleted(ShareRecipient $recipient): array { + $qb = $this->connection->getQueryBuilder(); + $result = $qb + ->selectDistinct('share_id') + ->from('sharing_share_recipients') + ->where($qb->expr()->eq('recipient_class', $qb->createNamedParameter($recipient->class))) + ->andWhere($qb->expr()->eq('recipient_value', $qb->createNamedParameter($recipient->value))) + ->andWhere( + $recipient->instance === null + ? $qb->expr()->isNull('recipient_instance') + : $qb->expr()->eq('recipient_instance', $qb->createNamedParameter($recipient->instance)) + ) + ->executeQuery(); + + /** @var list $ids */ + $ids = $result->fetchFirstColumn(); + if ($ids === []) { + return []; + } + + $ids = array_map(static fn (string|int $id): string => (string)$id, $ids); + + $qb = $this->connection->getQueryBuilder(); + $qb + ->delete('sharing_share_recipients') + ->where($qb->expr()->eq('recipient_class', $qb->createNamedParameter($recipient->class))) + ->andWhere($qb->expr()->eq('recipient_value', $qb->createNamedParameter($recipient->value))) + ->andWhere( + $recipient->instance === null + ? $qb->expr()->isNull('recipient_instance') + : $qb->expr()->eq('recipient_instance', $qb->createNamedParameter($recipient->instance)) + ) + ->executeStatement(); + + return $ids; + } + + #[\Override] + public function onInitiatorDeleted(IUser $initiator): array { + $qb = $this->connection->getQueryBuilder(); + $result = $qb + ->selectDistinct('share_id') + ->from('sharing_share_recipients') + ->andWhere($qb->expr()->isNull('initiator_instance')) + ->andWhere($qb->expr()->eq('initiator_user_id', $qb->createNamedParameter($initiator->getUID()))) + ->executeQuery(); + + /** @var list $ids */ + $ids = $result->fetchFirstColumn(); + if ($ids === []) { + return []; + } + + $ids = array_map(static fn (string|int $id): string => (string)$id, $ids); + + foreach ($ids as $id) { + $owner = $this->getShareOwner($id); + + $qb = $this->connection->getQueryBuilder(); + $qb + ->update('sharing_share_recipients') + ->set('initiator_user_id', $qb->createNamedParameter($owner->userId)) + ->set('initiator_instance', $qb->createNamedParameter($owner->instance)) + ->where($qb->expr()->eq('share_id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->isNull('initiator_instance')) + ->andWhere($qb->expr()->eq('initiator_user_id', $qb->createNamedParameter($initiator->getUID()))) + ->executeStatement(); + } + + return $ids; + } + + #[\Override] + public function updateShareRecipientSecret(string $id, ShareRecipient $recipient, string $secret): void { + $qb = $this->connection->getQueryBuilder(); + $rowCount = $qb + ->update('sharing_share_recipients') + ->set('recipient_secret', $qb->createNamedParameter($secret)) + ->where($qb->expr()->eq('share_id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->eq('recipient_class', $qb->createNamedParameter($recipient->class))) + ->andWhere($qb->expr()->eq('recipient_value', $qb->createNamedParameter($recipient->value))) + ->andWhere( + $recipient->instance === null + ? $qb->expr()->isNull('recipient_instance') + : $qb->expr()->eq('recipient_instance', $qb->createNamedParameter($recipient->instance)) + ) + ->executeStatement(); + if ($rowCount === 0) { + throw new ShareNotFoundException(); + } + } + + #[\Override] + public function updateShareProperty(string $id, ShareProperty $property): void { + $value = $property->value; + + $propertyType = $this->registry->getPropertyTypes()[$property->class]; + + if ($propertyType instanceof ISharePropertyTypeModifyValue) { + $qb = $this->connection->getQueryBuilder(); + $qb + ->select('sp.property_value') + ->from('sharing_share_properties', 'sp') + ->where($qb->expr()->eq('sp.share_id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->eq('sp.property_class', $qb->createNamedParameter($property->class))); + + /** @var string|false $oldValue */ + $oldValue = $qb->executeQuery()->fetchOne(); + if ($oldValue === false) { + $oldValue = null; + } + + $value = $propertyType->modifyValueOnSave($oldValue, $property->value); + } + + $qb = $this->connection->getQueryBuilder(); + $rowCount = $qb + ->update('sharing_share_properties') + ->set('property_value', $qb->createNamedParameter($value)) + ->where($qb->expr()->eq('share_id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->eq('property_class', $qb->createNamedParameter($property->class))) + ->executeStatement(); + if ($rowCount === 0) { + throw new ShareNotFoundException(); + } + } + + #[\Override] + public function updateSharePermission(string $id, SharePermission $permission): void { + $qb = $this->connection->getQueryBuilder(); + $rowCount = $qb + ->update('sharing_share_permissions') + ->set('permission_enabled', $qb->createNamedParameter($permission->enabled, IQueryBuilder::PARAM_BOOL)) + ->where($qb->expr()->eq('share_id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->eq('permission_class', $qb->createNamedParameter($permission->class))) + ->executeStatement(); + if ($rowCount === 0) { + throw new ShareNotFoundException(); + } + } + + #[\Override] + public function selectSharePermissionPreset(string $id, string $permissionPresetClass): void { + $qb = $this->connection->getQueryBuilder(); + $qb + ->update('sharing_share_permissions') + ->set('permission_enabled', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)) + ->where($qb->expr()->eq('share_id', $qb->createNamedParameter($id))) + ->executeStatement(); + + $permissionPresetCompatiblePermissionTypeClasses = $this->registry->getPermissionPresetCompatiblePermissionTypeClasses()[$permissionPresetClass]; + foreach (array_chunk($permissionPresetCompatiblePermissionTypeClasses, 1000) as $chunk) { + // Some permissions might not be compatible with the share, just ignore it and update the ones that are present. + $qb = $this->connection->getQueryBuilder(); + $qb + ->update('sharing_share_permissions') + ->set('permission_enabled', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)) + ->where($qb->expr()->eq('share_id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->in('permission_class', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_STR_ARRAY))) + ->executeStatement(); + } + + // We don't check if at least one permission is enabled and otherwise change the share state to draft, because we know every preset has at least one permission belonging to it. + } + + #[\Override] + public function deleteShare(string $id): void { + $qb = $this->connection->getQueryBuilder(); + $rowCount = $qb + ->delete('sharing_share') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))) + ->executeStatement(); + if ($rowCount === 0) { + throw new ShareNotFoundException(); + } + + // The other tables are cleared by their foreign key constraints and on delete cascade. + } + + #[\Override] + public function getShare(ShareAccessContext $accessContext, string $id): Share { + $shares = $this->list($accessContext, $id, null, null, null, null); + if (count($shares) !== 1) { + throw new ShareNotFoundException(); + } + + return $shares[0]; + } + + #[\Override] + public function getShares(ShareAccessContext $accessContext, ?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?string $lastShareID, ?int $limit): array { + return $this->list($accessContext, null, $filterSourceTypeClass, $filterSourceTypeValue, $lastShareID, $limit); + } + + #[\Override] + public function hasShare(string $id): bool { + $qb = $this->connection->getQueryBuilder(); + + $result = $qb + ->select('id') + ->from('sharing_share') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))) + ->executeQuery(); + + return $result->fetchOne() !== false; + } + + #[\Override] + public function getShareOwner(string $id): ShareUser { + $qb = $this->connection->getQueryBuilder(); + $qb + ->select('owner_user_id', 'owner_instance') + ->from('sharing_share') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); + + $row = $qb->executeQuery()->fetchAssociative(); + if ($row === false) { + throw new ShareNotFoundException(); + } + + /** @var non-empty-string $userId */ + $userId = $row['owner_user_id']; + /** @var non-empty-string $instance */ + $instance = $row['owner_instance']; + + return new ShareUser( + $userId, + $instance, + ); + } + + /** + * @param non-empty-list $ids + */ + #[\Override] + public function setLastUpdated(array $ids, int $lastUpdated): void { + foreach (array_chunk($ids, 1000) as $chunk) { + $qb = $this->connection->getQueryBuilder(); + + $rowCount = $qb + ->update('sharing_share') + ->set('last_updated', $qb->createNamedParameter($lastUpdated, IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->in('id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))) + ->executeStatement(); + if ($rowCount !== count($chunk)) { + throw new ShareNotFoundException(); + } + } + } + + private function hideDisabledUserShares(): bool { + return $this->appConfig->getValueString('files_sharing', 'hide_disabled_user_shares', 'yes') === 'yes'; + } + + // TODO: Split up the method + + /** + * @param ?class-string $filterSourceTypeClass + * @return list + */ + private function list(ShareAccessContext $accessContext, ?string $filterShareID, ?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?string $lastShareID, ?int $limit): array { + /** @var array, list> $recipientTypeValues */ + $recipientTypeValues = []; + + /** @var list $queries */ + $queries = []; + if ($accessContext->overrideChecks) { + $queries[] = $this->connection->getQueryBuilder(); + } else { + if ($accessContext->currentUser instanceof IUser) { + $qb = $this->connection->getQueryBuilder(); + $qb->where($qb->expr()->eq('s.owner_user_id', $qb->createNamedParameter($accessContext->currentUser->getUID()))); + $queries[] = $qb; + } + + foreach ($this->registry->getRecipientTypes() as $recipientType) { + $recipientValues = $recipientType->getRecipients($accessContext->currentUser, $accessContext->arguments[$recipientType::class] ?? null); + if ($recipientValues !== []) { + $recipientTypeValues[$recipientType::class] = $recipientValues; + } + } + + // Do not add a query if no recipients matched, otherwise all shares will be returned. + if ($recipientTypeValues !== []) { + $qb = $this->connection->getQueryBuilder(); + $qb->innerJoin('s', 'sharing_share_recipients', 'sr', $qb->expr()->andX( + $qb->expr()->eq('s.state', $qb->createNamedParameter(ShareState::Active->value)), + $qb->expr()->eq('s.id', 'sr.share_id'), + )); + + foreach ($recipientTypeValues as $recipientTypeClass => $recipientValues) { + $qb->orWhere($qb->expr()->andX( + $qb->expr()->eq('sr.recipient_class', $qb->createNamedParameter($recipientTypeClass)), + // TODO: Add chunking + $qb->expr()->in('sr.recipient_value', $qb->createNamedParameter($recipientValues, IQueryBuilder::PARAM_STR_ARRAY)), + $qb->expr()->isNull('sr.recipient_instance'), + )); + } + + $queries[] = $qb; + } + + if ($filterShareID !== null && $accessContext->secret !== null) { + $qb = $this->connection->getQueryBuilder(); + $qb->innerJoin('s', 'sharing_share_recipients', 'sr', $qb->expr()->andX( + $qb->expr()->eq('s.state', $qb->createNamedParameter(ShareState::Active->value)), + $qb->expr()->eq('s.id', 'sr.share_id'), + $qb->expr()->eq('sr.recipient_secret', $qb->createNamedParameter($accessContext->secret)), + )); + + $queries[] = $qb; + } + } + + // The key type is array-key, because PHP will automatically cast the value. We can't type it as integer though, because we need to also support 32 bit systems and there the autocasting doesn't happen, if the value is too large. + /** @var array, recipients: list, properties: array, ShareProperty>, permissions: array, SharePermission>}> $shares */ + $shares = []; + foreach ($queries as $qb) { + $qb + ->select( + 's.id', + 's.owner_user_id', + 's.owner_instance', + 's.last_updated', + 's.state', + ) + ->from('sharing_share', 's') + ->orderBy('s.id', 'ASC'); + + if ($filterShareID !== null) { + $qb->andWhere($qb->expr()->eq('s.id', $qb->createNamedParameter($filterShareID))); + } + + if ($filterSourceTypeClass !== null) { + $sourceTypeFilters = [ + $qb->expr()->eq('s.id', 'ss.share_id'), + $qb->expr()->eq('ss.source_class', $qb->createNamedParameter($filterSourceTypeClass)), + ]; + + if ($filterSourceTypeValue !== null) { + $sourceTypeFilters[] = $qb->expr()->eq('ss.source_value', $qb->createNamedParameter($filterSourceTypeValue)); + } + + $qb->innerJoin('s', 'sharing_share_sources', 'ss', $qb->expr()->andX(...$sourceTypeFilters)); + } + + if ($lastShareID !== null) { + $qb->andWhere($qb->expr()->gt('s.id', $qb->createNamedParameter($lastShareID))); + } + + if ($limit !== null) { + $qb->setMaxResults($limit); + } + + $result = $qb->executeQuery(); + $rows = $result->fetchAll(); + foreach ($rows as $row) { + /** @var non-empty-string $id */ + $id = (string)$row['id']; + /** @var non-empty-string $ownerUserId */ + $ownerUserId = $row['owner_user_id']; + /** @var ?non-empty-string $ownerInstance */ + $ownerInstance = $row['owner_instance']; + + /** @psalm-suppress PossiblyNullReference The share is automatically deleted, when the owner is deleted. */ + if ($ownerInstance === null && !$accessContext->overrideChecks && $this->hideDisabledUserShares() && !$this->userManager->get($ownerUserId)->isEnabled()) { + continue; + } + + /** @var non-negative-int $lastUpdated */ + $lastUpdated = $row['last_updated']; + /** @var string $state */ + $state = $row['state']; + $shares[$id] ??= [ + 'id' => $id, + 'owner' => new ShareUser($ownerUserId, $ownerInstance), + 'last_updated' => $lastUpdated, + 'state' => ShareState::from($state), + 'sources' => [], + 'recipients' => [], + 'properties' => [], + 'permissions' => [], + ]; + } + } + + if ($shares === []) { + return []; + } + + // The queries are limited already, but could return more results in total, so discard them here. + if ($limit !== null) { + $shares = array_slice($shares, 0, $limit, true); + } + + /** @var list> $chunks */ + $chunks = array_chunk(array_keys($shares), 1000); + + $registrySourceTypes = $this->registry->getSourceTypes(); + /** @var array, bool>> $shareSourceTypeClasses */ + $shareSourceTypeClasses = []; + foreach ($chunks as $chunk) { + $qb = $this->connection->getQueryBuilder(); + $qb + ->select( + 'ss.share_id', + 'ss.source_class', + 'ss.source_value', + ) + ->from('sharing_share_sources', 'ss') + ->where($qb->expr()->in('ss.share_id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))); + + $result = $qb->executeQuery(); + foreach ($result->fetchAll() as $row) { + /** @var class-string $typeClass */ + $typeClass = $row['source_class']; + if (!isset($registrySourceTypes[$typeClass])) { + // Skip sources that are currently not compatible, but don't remove them. + continue; + } + + /** @var non-empty-string $value */ + $value = $row['source_value']; + /** @var non-empty-string $id */ + $id = (string)$row['share_id']; + $shares[$id]['sources'][] = new ShareSource( + $typeClass, + $value, + ); + + $shareSourceTypeClasses[$id] ??= []; + $shareSourceTypeClasses[$id][$typeClass] = true; + } + } + + $registryRecipientTypes = $this->registry->getRecipientTypes(); + /** @var array, bool>> $shareRecipientTypeClasses */ + $shareRecipientTypeClasses = []; + foreach ($chunks as $chunk) { + $qb = $this->connection->getQueryBuilder(); + $qb + ->select( + 'sr.share_id', + 'sr.recipient_class', + 'sr.recipient_value', + 'sr.recipient_instance', + 'sr.recipient_secret', + 'sr.initiator_user_id', + 'sr.initiator_instance', + ) + ->from('sharing_share_recipients', 'sr') + ->where($qb->expr()->in('sr.share_id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))); + + foreach ($qb->executeQuery()->fetchAll() as $row) { + /** @var class-string $typeClass */ + $typeClass = $row['recipient_class']; + if (!isset($registryRecipientTypes[$typeClass])) { + // Skip recipients that are currently not compatible, but don't remove them. + continue; + } + + /** @var non-empty-string $id */ + $id = (string)$row['share_id']; + /** @var non-empty-string $initiatorUserId */ + $initiatorUserId = $row['initiator_user_id']; + /** @var ?non-empty-string $initiatorInstance */ + $initiatorInstance = $row['initiator_instance']; + + /** @psalm-suppress PossiblyNullReference The initiator is automatically promoted to the owner, when the initiator is deleted. */ + if ($initiatorInstance === null && !$accessContext->overrideChecks && !$shares[$id]['owner']->isCurrentUser($accessContext) && $this->hideDisabledUserShares() && !$this->userManager->get($initiatorUserId)->isEnabled()) { + continue; + } + + /** @var non-empty-string $value */ + $value = $row['recipient_value']; + /** @var ?non-empty-string $instance */ + $instance = $row['recipient_instance']; + // The secret is only removed in the next step, because we still need it to check if the current access context still has access to the share, after the recipients of disabled initiators have been skipped. + /** @var non-empty-string $secret */ + $secret = $row['recipient_secret']; + + $shares[$id]['recipients'][] = new ShareRecipient( + $typeClass, + $value, + $instance, + $secret, + new ShareUser( + $initiatorUserId, + $initiatorInstance, + ), + ); + + $shareRecipientTypeClasses[$id] ??= []; + $shareRecipientTypeClasses[$id][$typeClass] = true; + } + } + + // Some recipients might have been removed if the initiator was disabled, so check again if this share can be accessed by the current user as a recipient. + // This logic is a bit duplicated with the SQL logic that selects shares based on the secret and the recipient type values, but neither can be removed. + if (!$accessContext->overrideChecks) { + foreach ($shares as $id => &$share) { + if ($share['owner']->isCurrentUser($accessContext)) { + continue; + } + + $isAnyMatchingRecipient = false; + foreach ($share['recipients'] as &$recipient) { + $isMatchingRecipient = false; + if (($accessContext->secret !== null && $recipient->secret === $accessContext->secret) + || ($recipient->initiator !== null && $recipient->initiator->isCurrentUser($accessContext))) { + $isMatchingRecipient = true; + } + + foreach ($recipientTypeValues as $recipientTypeClass => $recipientValues) { + if ($recipient->instance === null && $recipient->class === $recipientTypeClass && in_array($recipient->value, $recipientValues, true)) { + $isMatchingRecipient = true; + break; + } + } + + if ($isMatchingRecipient) { + $isAnyMatchingRecipient = true; + } else { + // Remove the secret if the recipient didn't match + $recipient = new ShareRecipient( + $recipient->class, + $recipient->value, + $recipient->instance, + null, + $recipient->initiator, + ); + } + } + + unset($recipient); + + if (!$isAnyMatchingRecipient) { + unset($shares[$id]); + } + } + + unset($share); + } + + if ($shares === []) { + return []; + } + + /** @var list> $chunks */ + $chunks = array_chunk(array_keys($shares), 1000); + + $registryPropertyTypes = $this->registry->getPropertyTypes(); + $registryPropertyTypeCompatibleSourceTypeClasses = $this->registry->getPropertyTypeCompatibleSourceTypeClasses(); + $registryPropertyTypeCompatibleRecipientTypeClasses = $this->registry->getPropertyTypeCompatibleRecipientTypes(); + + foreach ($chunks as $chunk) { + $qb = $this->connection->getQueryBuilder(); + $qb + ->select( + 'sp.share_id', + 'sp.property_class', + 'sp.property_value', + ) + ->from('sharing_share_properties', 'sp') + ->where($qb->expr()->in('sp.share_id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))); + + $result = $qb->executeQuery(); + foreach ($result->fetchAll() as $row) { + /** @var non-empty-string $id */ + $id = (string)$row['share_id']; + if (!isset($shareSourceTypeClasses[$id], $shareRecipientTypeClasses[$id])) { + continue; + } + + /** @var class-string $propertyTypeClass */ + $propertyTypeClass = $row['property_class']; + if (!isset($registryPropertyTypeCompatibleSourceTypeClasses[$propertyTypeClass], $registryPropertyTypeCompatibleRecipientTypeClasses[$propertyTypeClass])) { + // Skip properties that are currently not compatible, but don't remove them. + continue; + } + + if (array_intersect($registryPropertyTypeCompatibleSourceTypeClasses[$propertyTypeClass], array_keys($shareSourceTypeClasses[$id])) === []) { + // Skip properties that are currently not compatible, but don't remove them. + continue; + } + + if (array_intersect($registryPropertyTypeCompatibleRecipientTypeClasses[$propertyTypeClass], array_keys($shareRecipientTypeClasses[$id])) === []) { + // Skip properties that are currently not compatible, but don't remove them. + continue; + } + + /** @var ?string $value */ + $value = $row['property_value']; + + $propertyType = $registryPropertyTypes[$propertyTypeClass]; + if ($propertyType instanceof ISharePropertyTypeModifyValue) { + $value = $propertyType->modifyValueOnLoad($value); + } + + $shares[$id]['properties'][$propertyTypeClass] = new ShareProperty($propertyTypeClass, $value); + } + } + + foreach (array_keys($shares) as $id) { + foreach ($registryPropertyTypes as $propertyTypeClass => $propertyType) { + if ( + !isset($shares[$id]['properties'][$propertyTypeClass]) + && isset($shareSourceTypeClasses[$id], $shareRecipientTypeClasses[$id]) + && array_intersect($registryPropertyTypeCompatibleSourceTypeClasses[$propertyTypeClass], array_keys($shareSourceTypeClasses[$id])) !== [] + && array_intersect($registryPropertyTypeCompatibleRecipientTypeClasses[$propertyTypeClass], array_keys($shareRecipientTypeClasses[$id])) !== []) { + $value = $propertyType->getDefaultValue(); + + $timestamp = $this->manager->generateTimestamp(); + $this->setLastUpdated([(string)$id], $timestamp); + + $qb = $this->connection->getQueryBuilder(); + $qb + ->insert('sharing_share_properties') + ->values([ + 'share_id' => $qb->createNamedParameter($id), + 'property_class' => $qb->createNamedParameter($propertyTypeClass), + 'property_value' => $qb->createNamedParameter($value), + ]) + ->executeStatement(); + + $shares[$id]['properties'][$propertyTypeClass] = new ShareProperty($propertyTypeClass, $value); + $shares[$id]['last_updated'] = $timestamp; + } + } + } + + $registrySourceTypePermissionTypeClasses = $this->registry->getSourceTypePermissionTypeClasses(); + $registryGenericPermissionTypeClasses = $this->registry->getGenericPermissionTypeClasses(); + + /** @var array, bool>> $shareCompatiblePermissionTypeClasses */ + $shareCompatiblePermissionTypeClasses = []; + foreach (array_keys($shares) as $id) { + $shareCompatiblePermissionTypeClasses[$id] = []; + foreach ($registryGenericPermissionTypeClasses as $permissionTypeClass) { + $shareCompatiblePermissionTypeClasses[$id][$permissionTypeClass] = true; + } + + if (isset($shareSourceTypeClasses[$id])) { + foreach (array_keys($shareSourceTypeClasses[$id]) as $shareSourceTypeClass) { + if (isset($registrySourceTypePermissionTypeClasses[$shareSourceTypeClass])) { + foreach ($registrySourceTypePermissionTypeClasses[$shareSourceTypeClass] as $permissionTypeClass) { + $shareCompatiblePermissionTypeClasses[$id][$permissionTypeClass] = true; + } + } + } + } + } + + foreach ($chunks as $chunk) { + $qb = $this->connection->getQueryBuilder(); + $qb + ->select( + 'sp.share_id', + 'sp.permission_class', + 'sp.permission_enabled', + ) + ->from('sharing_share_permissions', 'sp') + ->where($qb->expr()->in('sp.share_id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))); + + $result = $qb->executeQuery(); + foreach ($result->fetchAll() as $row) { + /** @var non-empty-string $id */ + $id = (string)$row['share_id']; + + /** @var class-string $permissionTypeClass */ + $permissionTypeClass = $row['permission_class']; + if (!isset($shareCompatiblePermissionTypeClasses[$id][$permissionTypeClass])) { + // Skip permissions that are currently not compatible, but don't remove them. + continue; + } + + $enabled = (bool)$row['permission_enabled']; + $shares[$id]['permissions'][$permissionTypeClass] = new SharePermission($permissionTypeClass, $enabled); + } + } + + $permissionTypes = $this->registry->getPermissionTypes(); + + foreach (array_keys($shares) as $id) { + foreach (array_keys($shareCompatiblePermissionTypeClasses[$id]) as $permissionTypeClass) { + $permissionType = $permissionTypes[$permissionTypeClass]; + if (!isset($shares[$id]['permissions'][$permissionTypeClass])) { + $enabled = $permissionType->isEnabledByDefault(); + + $timestamp = $this->manager->generateTimestamp(); + $this->setLastUpdated([(string)$id], $timestamp); + + $qb = $this->connection->getQueryBuilder(); + $qb + ->insert('sharing_share_permissions') + ->values([ + 'share_id' => $qb->createNamedParameter($id), + 'permission_class' => $qb->createNamedParameter($permissionTypeClass), + 'permission_enabled' => $qb->createNamedParameter($enabled, IQueryBuilder::PARAM_BOOL), + ]) + ->executeStatement(); + + $shares[$id]['permissions'][$permissionTypeClass] = new SharePermission($permissionTypeClass, $enabled); + $shares[$id]['last_updated'] = $timestamp; + } + } + } + + $shares = array_map(static fn (array $share): Share => new Share( + $share['id'], + $share['owner'], + $share['last_updated'], + $share['state'], + $share['sources'], + $share['recipients'], + $share['properties'], + $share['permissions'], + ), $shares); + + if (!$accessContext->overrideChecks) { + $filterPropertyTypes = array_filter($registryPropertyTypes, static fn (ISharePropertyType $propertyType): bool => $propertyType instanceof ISharePropertyTypeFilter); + if ($filterPropertyTypes !== []) { + $shares = array_filter($shares, static function (Share $share) use ($accessContext, $filterPropertyTypes): bool { + if ($share->owner->isCurrentUser($accessContext)) { + return true; + } + + foreach ($filterPropertyTypes as $filterPropertyType) { + if ($filterPropertyType->isFiltered($accessContext, $share)) { + return false; + } + } + + return true; + }); + } + } + + return array_values($shares); + } +} diff --git a/apps/sharing/openapi.json b/apps/sharing/openapi.json new file mode 100644 index 0000000000000..6856c3e478e86 --- /dev/null +++ b/apps/sharing/openapi.json @@ -0,0 +1,3235 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "sharing", + "version": "0.0.1", + "description": "TODO", + "license": { + "name": "AGPL-3.0-or-later" + } + }, + "components": { + "securitySchemes": { + "basic_auth": { + "type": "http", + "scheme": "basic" + }, + "bearer_auth": { + "type": "http", + "scheme": "bearer" + } + }, + "schemas": { + "Capabilities": { + "type": "object", + "properties": { + "sharing": { + "type": "object", + "required": [ + "api_versions", + "source_types", + "permission_presets" + ], + "properties": { + "api_versions": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "v1" + ] + } + }, + "source_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SourceType" + } + }, + "permission_presets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionPreset" + } + } + } + } + } + }, + "Icon": { + "anyOf": [ + { + "$ref": "#/components/schemas/IconSVG" + }, + { + "$ref": "#/components/schemas/IconURL" + } + ] + }, + "IconSVG": { + "type": "object", + "required": [ + "svg" + ], + "properties": { + "svg": { + "type": "string", + "description": "An SVG using the currentColor value for dynamic theming.", + "minLength": 1 + } + } + }, + "IconURL": { + "type": "object", + "required": [ + "light", + "dark" + ], + "properties": { + "light": { + "type": "string", + "description": "An absolute URL to an image suitable for light theme.", + "minLength": 1 + }, + "dark": { + "type": "string", + "description": "An absolute URL to an image suitable for dark theme.", + "minLength": 1 + } + } + }, + "OCSMeta": { + "type": "object", + "required": [ + "status", + "statuscode" + ], + "properties": { + "status": { + "type": "string" + }, + "statuscode": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "totalitems": { + "type": "string" + }, + "itemsperpage": { + "type": "string" + } + } + }, + "Permission": { + "type": "object", + "required": [ + "class", + "source_class", + "display_name", + "hint", + "priority", + "presets", + "enabled" + ], + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "source_class": { + "type": "string", + "nullable": true, + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "hint": { + "type": "string", + "nullable": true, + "minLength": 1 + }, + "priority": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 100 + }, + "presets": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "enabled": { + "type": "boolean" + } + } + }, + "PermissionPreset": { + "type": "object", + "required": [ + "class", + "display_name", + "hint" + ], + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "hint": { + "type": "string", + "nullable": true, + "minLength": 1 + } + } + }, + "Property": { + "type": "object", + "required": [ + "class", + "display_name", + "hint", + "priority", + "required", + "advanced", + "value" + ], + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "hint": { + "type": "string", + "nullable": true, + "minLength": 1 + }, + "priority": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 100 + }, + "required": { + "type": "boolean" + }, + "advanced": { + "type": "boolean" + }, + "value": { + "type": "string", + "nullable": true + } + } + }, + "PropertyBoolean": { + "allOf": [ + { + "$ref": "#/components/schemas/Property" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "boolean" + ] + } + } + } + ] + }, + "PropertyDate": { + "allOf": [ + { + "$ref": "#/components/schemas/Property" + }, + { + "type": "object", + "required": [ + "type", + "min_date", + "max_date" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "date" + ] + }, + "min_date": { + "type": "string", + "nullable": true, + "description": "ISO 8601", + "minLength": 1 + }, + "max_date": { + "type": "string", + "nullable": true, + "description": "ISO 8601", + "minLength": 1 + } + } + } + ] + }, + "PropertyEnum": { + "allOf": [ + { + "$ref": "#/components/schemas/Property" + }, + { + "type": "object", + "required": [ + "type", + "valid_values" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "enum" + ] + }, + "valid_values": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + } + } + ] + }, + "PropertyPassword": { + "allOf": [ + { + "$ref": "#/components/schemas/Property" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "password" + ] + } + } + } + ] + }, + "PropertyString": { + "allOf": [ + { + "$ref": "#/components/schemas/Property" + }, + { + "type": "object", + "required": [ + "type", + "min_length", + "max_length" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "string" + ] + }, + "min_length": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 1 + }, + "max_length": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 1 + } + } + } + ] + }, + "Recipient": { + "type": "object", + "required": [ + "class", + "value", + "instance", + "display_name", + "icon", + "secret", + "initiator" + ], + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string", + "minLength": 1 + }, + "instance": { + "type": "string", + "nullable": true, + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "icon": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Icon" + } + ] + }, + "secret": { + "type": "object", + "required": [ + "updatable" + ], + "properties": { + "updatable": { + "type": "boolean" + }, + "value": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string", + "minLength": 1 + } + } + }, + "initiator": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/User" + } + ] + } + } + }, + "Share": { + "type": "object", + "required": [ + "id", + "owner", + "last_updated", + "state", + "sources", + "recipients", + "properties", + "permissions", + "permission_preset" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "owner": { + "$ref": "#/components/schemas/User" + }, + "last_updated": { + "type": "integer", + "format": "int64", + "description": "Unix time in milliseconds", + "minimum": 0 + }, + "state": { + "$ref": "#/components/schemas/State" + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Source" + } + }, + "recipients": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Recipient" + } + }, + "properties": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/PropertyDate" + }, + { + "$ref": "#/components/schemas/PropertyEnum" + }, + { + "$ref": "#/components/schemas/PropertyBoolean" + }, + { + "$ref": "#/components/schemas/PropertyPassword" + }, + { + "$ref": "#/components/schemas/PropertyString" + } + ] + } + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permission" + } + }, + "permission_preset": { + "type": "string", + "nullable": true, + "minLength": 1 + } + } + }, + "Source": { + "type": "object", + "required": [ + "class", + "value", + "display_name", + "icon" + ], + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string", + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "icon": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Icon" + } + ] + } + } + }, + "SourceType": { + "type": "object", + "required": [ + "class" + ], + "properties": { + "class": { + "type": "string", + "minLength": 1 + } + } + }, + "State": { + "type": "string", + "enum": [ + "active", + "draft", + "deleted" + ] + }, + "User": { + "type": "object", + "required": [ + "user_id", + "instance", + "display_name", + "icon" + ], + "properties": { + "user_id": { + "type": "string", + "minLength": 1 + }, + "instance": { + "type": "string", + "nullable": true, + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "icon": { + "$ref": "#/components/schemas/Icon" + } + } + } + } + }, + "paths": { + "/ocs/v2.php/apps/sharing/api/v1/recipients": { + "get": { + "operationId": "api_v1-search-recipients", + "summary": "Search for recpients that can be added to a share.", + "tags": [ + "api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "recipientTypeClasses[]", + "in": "query", + "description": "Type class of recipients to filter by", + "schema": { + "type": "array", + "nullable": true, + "items": { + "type": "string", + "minLength": 1 + } + } + }, + { + "name": "query", + "in": "query", + "description": "The query to search for", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "The maximum number of participants", + "schema": { + "type": "integer", + "format": "int64", + "default": 10, + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "offset", + "in": "query", + "description": "The offset of the participants", + "schema": { + "type": "integer", + "format": "int64", + "default": 0, + "minimum": 0 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Recipients returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Recipient" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid recipient search parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/secret": { + "get": { + "operationId": "api_v1-generate-secret", + "summary": "Generate a new secret.", + "tags": [ + "api_v1" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Generated secret returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share": { + "post": { + "operationId": "api_v1-create-share", + "summary": "Create a new share.", + "tags": [ + "api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "Share created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Share" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}/state": { + "put": { + "operationId": "api_v1-update-share-state", + "summary": "Update the state of a share.", + "tags": [ + "api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "$ref": "#/components/schemas/State", + "description": "New state of the share" + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share state updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Share" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid share state", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Updating the share state is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}/source": { + "post": { + "operationId": "api_v1-add-share-source", + "summary": "Add a new source to a share.", + "tags": [ + "api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "class", + "value" + ], + "properties": { + "class": { + "type": "string", + "description": "Type class of the source", + "minLength": 1 + }, + "value": { + "type": "string", + "description": "Value of the source", + "minLength": 1 + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share source added successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Share" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid share source", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Adding the share source is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "api_v1-remove-share-source", + "summary": "Remove an existing source from a share.", + "tags": [ + "api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "class", + "in": "query", + "description": "Type class of the source", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "value", + "in": "query", + "description": "Value of the source", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share source removed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Share" + } + } + } + } + } + } + } + }, + "403": { + "description": "Removing the share source is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}/recipient": { + "post": { + "operationId": "api_v1-add-share-recipient", + "summary": "Add a new recipient to a share.", + "tags": [ + "api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "class", + "value" + ], + "properties": { + "class": { + "type": "string", + "description": "Type class of the recipient", + "minLength": 1 + }, + "value": { + "type": "string", + "description": "Value of the recipient", + "minLength": 1 + }, + "instance": { + "type": "string", + "nullable": true, + "description": "Instance of the recipient", + "minLength": 1 + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share recipient added successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Share" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid share recipient", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Adding the share recipient is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "api_v1-remove-share-recipient", + "summary": "Remove an existing recipient from a share.", + "tags": [ + "api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "class", + "in": "query", + "description": "Type class of the recipient", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "value", + "in": "query", + "description": "Value of the recipient", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "instance", + "in": "query", + "description": "Instance of the recipient", + "schema": { + "type": "string", + "nullable": true, + "minLength": 1 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share recipient removed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Share" + } + } + } + } + } + } + } + }, + "403": { + "description": "Removing the share recipient is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}/recipient/secret": { + "put": { + "operationId": "api_v1-update-share-recipient-secret", + "summary": "Update the scecret of a recipient.", + "tags": [ + "api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "class", + "value", + "secret" + ], + "properties": { + "class": { + "type": "string", + "description": "Type class of the recipient", + "minLength": 1 + }, + "value": { + "type": "string", + "description": "Value of the recipient", + "minLength": 1 + }, + "instance": { + "type": "string", + "nullable": true, + "description": "Instance of the recipient", + "minLength": 1 + }, + "secret": { + "type": "string", + "description": "Secret of the recipient", + "minLength": 1 + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share recipient secret updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Share" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid secret", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Updating the share recipient secret is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}/property": { + "put": { + "operationId": "api_v1-update-share-property", + "summary": "Update a property of a share.", + "tags": [ + "api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "class" + ], + "properties": { + "class": { + "type": "string", + "description": "Type class of the property", + "minLength": 1 + }, + "value": { + "type": "string", + "nullable": true, + "description": "Value of the property" + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share property updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Share" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid share property", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Updating the share property is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}/permission": { + "put": { + "operationId": "api_v1-update-share-permission", + "summary": "Update a permission of a share.", + "tags": [ + "api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "class", + "enabled" + ], + "properties": { + "class": { + "type": "string", + "description": "Type class of the permission", + "minLength": 1 + }, + "enabled": { + "type": "boolean", + "description": "Enabled state of the permission" + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share permission updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Share" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid share permission", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Updating the share permission is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}/permission/preset": { + "put": { + "operationId": "api_v1-select-share-permission-preset", + "summary": "Select a permission preset for a share.", + "tags": [ + "api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "permissionPresetClass" + ], + "properties": { + "permissionPresetClass": { + "type": "string", + "description": "New permission preset of the share", + "minLength": 1 + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share permission preset selected successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Share" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid share permission preset", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Selecting the share permission preset is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}": { + "delete": { + "operationId": "api_v1-delete-share", + "summary": "Delete a share.", + "tags": [ + "api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "204": { + "description": "Share deleted" + }, + "403": { + "description": "Deleting the share is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "api_v1-get-share", + "summary": "Get a share.", + "tags": [ + "api_v1" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secret": { + "type": "string", + "nullable": true, + "default": null, + "description": "Secret of the share" + }, + "arguments": { + "type": "object", + "default": {}, + "description": "Arguments for accessing the share", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Share" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid arguments", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/shares": { + "get": { + "operationId": "api_v1-get-shares", + "summary": "Get multiple shares.", + "tags": [ + "api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "filterSourceTypeClass", + "in": "query", + "description": "Source type class to filter by.", + "schema": { + "type": "string", + "nullable": true, + "minLength": 1 + } + }, + { + "name": "filterSourceTypeValue", + "in": "query", + "description": "Source type value to filter by.", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "lastShareID", + "in": "query", + "description": "The ID of the previous share. This is used as an offset and only shares with higher IDs are returned.", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "limit", + "in": "query", + "description": "The number of shares to return.", + "schema": { + "type": "integer", + "format": "int64", + "default": 100, + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Shares returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Share" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + } + }, + "tags": [] +} diff --git a/apps/sharing/tests/CapabilitiesTest.php b/apps/sharing/tests/CapabilitiesTest.php new file mode 100644 index 0000000000000..d1681cab9668d --- /dev/null +++ b/apps/sharing/tests/CapabilitiesTest.php @@ -0,0 +1,77 @@ +registry = Server::get(ISharingRegistry::class); + $this->registry->clear(); + + $this->capabilities = Server::get(Capabilities::class); + } + + #[\Override] + protected function tearDown(): void { + $this->registry->clear(); + + parent::tearDown(); + } + + public function testGetCapabilities(): void { + $this->registry->registerSourceType(new TestShareSourceType1([])); + $this->registry->registerSourceType(new TestShareSourceType2([])); + $this->registry->registerPermissionPreset(new TestSharePermissionPreset1()); + $this->registry->registerPermissionPreset(new TestSharePermissionPreset2()); + + $this->assertEquals( + [ + Application::APP_ID => [ + 'api_versions' => ['v1'], + 'source_types' => [ + [ + 'class' => TestShareSourceType1::class, + ], + [ + 'class' => TestShareSourceType2::class, + ], + ], + 'permission_presets' => [ + [ + 'class' => TestSharePermissionPreset1::class, + 'display_name' => 'TestSharePermissionPreset1', + 'hint' => 'hint TestSharePermissionPreset1', + ], + [ + 'class' => TestSharePermissionPreset2::class, + 'display_name' => 'TestSharePermissionPreset2', + 'hint' => 'hint TestSharePermissionPreset2', + ], + ], + ], + ], + $this->capabilities->getCapabilities(), + ); + } +} diff --git a/apps/sharing/tests/Command/CommandTest.php b/apps/sharing/tests/Command/CommandTest.php new file mode 100644 index 0000000000000..73434fc141c27 --- /dev/null +++ b/apps/sharing/tests/Command/CommandTest.php @@ -0,0 +1,393 @@ +> */ + private array $commandClasses; + + #[Override] + public function setUp(): void { + parent::setUp(); + + $this->commandClasses = [ + AddShareRecipient::class, + AddShareSource::class, + CreateShare::class, + DeleteShare::class, + GetShare::class, + GetShares::class, + RemoveShareRecipient::class, + RemoveShareSource::class, + SelectSharePermissionPreset::class, + UpdateSharePermission::class, + UpdateShareProperty::class, + UpdateShareRecipientSecret::class, + UpdateShareState::class, + ]; + } + + public function testDefaultShareAccessContext(): void { + foreach ($this->commandClasses as $class) { + /** @psalm-suppress UnsafeInstantiation */ + $command = new $class( + Server::get(ISharingManager::class), + Server::get(ISharingRegistry::class), + Server::get(IFactory::class), + Server::get(IURLGenerator::class), + Server::get(IUserManager::class), + Server::get(IDBConnection::class), + ); + $this->assertEquals(new ShareAccessContext(overrideChecks: true), $command->accessContext, $class); + } + } + + /** + * @param class-string $class + * @param list> $arguments + * @param list> $options + */ + private function runCommand(ShareAccessContext $accessContext, string $class, array $arguments, array $options): string { + if (!in_array($class, $this->commandClasses, true)) { + throw new RuntimeException('Command class ' . $class . ' is not allowed to be used unless added to the array.'); + } + + $input = $this->createMock(Input::class); + $input + ->expects($this->exactly(count($arguments))) + ->method('getArgument') + ->willReturnMap($arguments); + $input + ->expects($this->exactly(count($options))) + ->method('getOption') + ->willReturnMap($options); + + $stderr = ''; + $errorOutput = $this->createMock(Output::class); + $errorOutput + ->method('writeln') + ->willReturnCallback(function (string $message) use (&$stderr): void { + $stderr .= $message . "\n"; + }); + + $stdout = ''; + $output = $this->createMock(ConsoleOutput::class); + $output + ->method('writeln') + ->willReturnCallback(function (string $message) use (&$stdout): void { + $stdout .= $message . "\n"; + }); + $output + ->method('getErrorOutput') + ->willReturn($errorOutput); + + /** @psalm-suppress UnsafeInstantiation */ + $command = new $class( + Server::get(ISharingManager::class), + Server::get(ISharingRegistry::class), + Server::get(IFactory::class), + Server::get(IURLGenerator::class), + Server::get(IUserManager::class), + Server::get(IDBConnection::class), + ); + + // We have to override the access context because commands always use force, but the tests don't expect that. + $command->accessContext = $accessContext; + + /** @psalm-suppress InaccessibleMethod */ + $exitCode = $command->execute($input, $output); + if ($exitCode === Base::SUCCESS) { + return $stdout; + } + + throw new HintException(rtrim($stderr, "\n")); + } + + #[Override] + protected function searchRecipients(ShareAccessContext $accessContext, ?array $recipientTypeClasses, string $query, int $limit, int $offset): array { + // We don't have a command for this, so we just call the real manager to make the test pass. + /** @psalm-suppress ArgumentTypeCoercion */ + return ShareRecipient::formatMultiple(Server::get(ISharingRegistry::class), Server::get(IFactory::class), Server::get(IURLGenerator::class), Server::get(IUserManager::class), $this->manager->searchRecipients($accessContext, $recipientTypeClasses, $query, $limit, $offset)); + } + + /** + * @return array + */ + #[Override] + protected function createShare(ShareAccessContext $accessContext): array { + $this->assertNotNull($accessContext->currentUser); + $stdout = $this->runCommand( + $accessContext, + CreateShare::class, + [ + ['owner', $accessContext->currentUser->getUID()], + ], + [], + ); + /** @psalm-suppress MixedReturnStatement */ + return json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + #[Override] + protected function updateShareState(ShareAccessContext $accessContext, string $id, ShareState $state): array { + $stdout = $this->runCommand( + $accessContext, + UpdateShareState::class, + [ + ['id', $id], + ['state', $state->value], + ], + [], + ); + /** @psalm-suppress MixedReturnStatement */ + return json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + #[Override] + protected function addShareSource(ShareAccessContext $accessContext, string $id, ShareSource $source): array { + $stdout = $this->runCommand( + $accessContext, + AddShareSource::class, + [ + ['id', $id], + ['class', $source->class], + ['value', $source->value], + ], + [], + ); + /** @psalm-suppress MixedReturnStatement */ + return json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + #[Override] + protected function removeShareSource(ShareAccessContext $accessContext, string $id, ShareSource $source): array { + $stdout = $this->runCommand( + $accessContext, + RemoveShareSource::class, + [ + ['id', $id], + ['class', $source->class], + ['value', $source->value], + ], + [], + ); + /** @psalm-suppress MixedReturnStatement */ + return json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + #[Override] + protected function addShareRecipient(ShareAccessContext $accessContext, string $id, ShareRecipient $recipient): array { + $stdout = $this->runCommand( + $accessContext, + AddShareRecipient::class, + [ + ['id', $id], + ['class', $recipient->class], + ['value', $recipient->value], + ['instance', $recipient->instance], + ], + [], + ); + /** @psalm-suppress MixedReturnStatement */ + return json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + #[Override] + protected function removeShareRecipient(ShareAccessContext $accessContext, string $id, ShareRecipient $recipient): array { + $stdout = $this->runCommand( + $accessContext, + RemoveShareRecipient::class, + [ + ['id', $id], + ['class', $recipient->class], + ['value', $recipient->value], + ['instance', $recipient->instance], + ], + [], + ); + /** @psalm-suppress MixedReturnStatement */ + return json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + #[Override] + protected function updateShareRecipientSecret(ShareAccessContext $accessContext, string $id, ShareRecipient $recipient, string $secret): array { + $stdout = $this->runCommand( + $accessContext, + UpdateShareRecipientSecret::class, + [ + ['id', $id], + ['class', $recipient->class], + ['value', $recipient->value], + ['instance', $recipient->instance], + ['secret', $secret], + ], + [], + ); + /** @psalm-suppress MixedReturnStatement */ + return json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + #[Override] + protected function updateShareProperty(ShareAccessContext $accessContext, string $id, ShareProperty $property): array { + $stdout = $this->runCommand( + $accessContext, + UpdateShareProperty::class, + [ + ['id', $id], + ['class', $property->class], + ['value', $property->value], + ], + [], + ); + /** @psalm-suppress MixedReturnStatement */ + return json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + #[Override] + protected function updateSharePermission(ShareAccessContext $accessContext, string $id, SharePermission $permission): array { + $stdout = $this->runCommand( + $accessContext, + UpdateSharePermission::class, + [ + ['id', $id], + ['class', $permission->class], + ['enabled', $permission->enabled ? 'true' : 'false'], + ], + [], + ); + /** @psalm-suppress MixedReturnStatement */ + return json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + #[Override] + protected function selectSharePermissionPreset(ShareAccessContext $accessContext, string $id, string $permissionPresetClass): array { + $stdout = $this->runCommand( + $accessContext, + SelectSharePermissionPreset::class, + [ + ['id', $id], + ['permission-preset', $permissionPresetClass], + ], + [], + ); + /** @psalm-suppress MixedReturnStatement */ + return json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } + + #[Override] + protected function deleteShare(ShareAccessContext $accessContext, string $id): void { + $this->runCommand( + $accessContext, + DeleteShare::class, + [ + ['id', $id], + ], + [], + ); + } + + /** + * @return array + */ + #[Override] + protected function getShare(ShareAccessContext $accessContext, string $id): array { + $stdout = $this->runCommand( + $accessContext, + GetShare::class, + [ + ['id', $id], + ], + [], + ); + /** @psalm-suppress MixedReturnStatement */ + return json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } + + /** + * @return array + */ + #[Override] + protected function getShares(ShareAccessContext $accessContext, ?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?string $lastShareID, ?int $limit): array { + $stdout = $this->runCommand( + $accessContext, + GetShares::class, + [], + [ + ['filter-source-type-class', $filterSourceTypeClass], + ['filter-source-type-value', $filterSourceTypeValue], + ['last-share-id', $lastShareID], + ['limit', $limit], + ], + ); + /** @psalm-suppress MixedReturnStatement */ + return json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/apps/sharing/tests/Controller/ApiV1ControllerTest.php b/apps/sharing/tests/Controller/ApiV1ControllerTest.php new file mode 100644 index 0000000000000..536f863824311 --- /dev/null +++ b/apps/sharing/tests/Controller/ApiV1ControllerTest.php @@ -0,0 +1,166 @@ +createUser('user', 'password'); + $this->assertNotFalse($user); + + self::loginAsUser($user->getUID()); + + $controller = new ApiV1Controller( + '', + Server::get(IRequest::class), + Server::get(IUserSession::class), + Server::get(ISharingManager::class), + Server::get(ISharingRegistry::class), + Server::get(IFactory::class), + Server::get(IURLGenerator::class), + Server::get(IUserManager::class), + Server::get(IDBConnection::class), + ); + + $this->assertEquals(new ShareAccessContext($user), $controller->accessContext); + + self::logout(); + } + + /** + * @param Closure(ApiV1Controller): DataResponse $closure + */ + private function executeRequest(ShareAccessContext $accessContext, Closure $closure): array { + $controller = new ApiV1Controller( + '', + Server::get(IRequest::class), + Server::get(IUserSession::class), + Server::get(ISharingManager::class), + Server::get(ISharingRegistry::class), + Server::get(IFactory::class), + Server::get(IURLGenerator::class), + Server::get(IUserManager::class), + Server::get(IDBConnection::class), + ); + + // We have to override the access context because the controller always use the user session, but the tests don't expect that. + $controller->accessContext = $accessContext; + + $response = $closure($controller); + if ($response->getStatus() < 400) { + /** @psalm-suppress MixedReturnStatement */ + return $response->getData(); + } + + /** @psalm-suppress MixedArgument */ + throw new HintException($response->getData()); + } + + #[Override] + protected function searchRecipients(ShareAccessContext $accessContext, ?array $recipientTypeClasses, string $query, int $limit, int $offset): array { + /** @psalm-suppress ArgumentTypeCoercion */ + return $this->executeRequest($accessContext, fn (ApiV1Controller $controller): DataResponse => $controller->searchRecipients($recipientTypeClasses, $query, $limit, $offset)); + } + + #[Override] + protected function createShare(ShareAccessContext $accessContext): array { + return $this->executeRequest($accessContext, fn (ApiV1Controller $controller): DataResponse => $controller->createShare()); + } + + #[Override] + protected function updateShareState(ShareAccessContext $accessContext, string $id, ShareState $state): array { + return $this->executeRequest($accessContext, fn (ApiV1Controller $controller): DataResponse => $controller->updateShareState($id, $state->value)); + } + + #[Override] + protected function addShareSource(ShareAccessContext $accessContext, string $id, ShareSource $source): array { + return $this->executeRequest($accessContext, fn (ApiV1Controller $controller): DataResponse => $controller->addShareSource($id, $source->class, $source->value)); + } + + #[Override] + protected function removeShareSource(ShareAccessContext $accessContext, string $id, ShareSource $source): array { + return $this->executeRequest($accessContext, fn (ApiV1Controller $controller): DataResponse => $controller->removeShareSource($id, $source->class, $source->value)); + } + + #[Override] + protected function addShareRecipient(ShareAccessContext $accessContext, string $id, ShareRecipient $recipient): array { + return $this->executeRequest($accessContext, fn (ApiV1Controller $controller): DataResponse => $controller->addShareRecipient($id, $recipient->class, $recipient->value, $recipient->instance)); + } + + #[Override] + protected function removeShareRecipient(ShareAccessContext $accessContext, string $id, ShareRecipient $recipient): array { + return $this->executeRequest($accessContext, fn (ApiV1Controller $controller): DataResponse => $controller->removeShareRecipient($id, $recipient->class, $recipient->value, $recipient->instance)); + } + + #[Override] + protected function updateShareRecipientSecret(ShareAccessContext $accessContext, string $id, ShareRecipient $recipient, string $secret): array { + /** @psalm-suppress ArgumentTypeCoercion */ + return $this->executeRequest($accessContext, fn (ApiV1Controller $controller): DataResponse => $controller->updateShareRecipientSecret($id, $recipient->class, $recipient->value, $recipient->instance, $secret)); + } + + #[Override] + protected function updateShareProperty(ShareAccessContext $accessContext, string $id, ShareProperty $property): array { + return $this->executeRequest($accessContext, fn (ApiV1Controller $controller): DataResponse => $controller->updateShareProperty($id, $property->class, $property->value)); + } + + #[Override] + protected function updateSharePermission(ShareAccessContext $accessContext, string $id, SharePermission $permission): array { + return $this->executeRequest($accessContext, fn (ApiV1Controller $controller): DataResponse => $controller->updateSharePermission($id, $permission->class, $permission->enabled)); + } + + #[Override] + protected function selectSharePermissionPreset(ShareAccessContext $accessContext, string $id, string $permissionPresetClass): array { + /** @psalm-suppress ArgumentTypeCoercion */ + return $this->executeRequest($accessContext, fn (ApiV1Controller $controller): DataResponse => $controller->selectSharePermissionPreset($id, $permissionPresetClass)); + } + + #[Override] + protected function deleteShare(ShareAccessContext $accessContext, string $id): void { + $this->executeRequest($accessContext, fn (ApiV1Controller $controller): DataResponse => $controller->deleteShare($id)); + } + + #[Override] + protected function getShare(ShareAccessContext $accessContext, string $id): array { + return $this->executeRequest(new ShareAccessContext($accessContext->currentUser, null, [], $accessContext->overrideChecks), fn (ApiV1Controller $controller): DataResponse => $controller->getShare($id, $accessContext->secret, $accessContext->arguments)); + } + + #[Override] + protected function getShares(ShareAccessContext $accessContext, ?string $filterSourceTypeClass, ?string $filterSourceTypeValue, ?string $lastShareID, ?int $limit): array { + return $this->executeRequest($accessContext, function (ApiV1Controller $controller) use ($filterSourceTypeClass, $filterSourceTypeValue, $lastShareID, $limit): DataResponse { + if ($limit !== null) { + /** @psalm-suppress ArgumentTypeCoercion */ + return $controller->getShares($filterSourceTypeClass, $filterSourceTypeValue, $lastShareID, $limit); + } + + /** @psalm-suppress ArgumentTypeCoercion */ + return $controller->getShares($filterSourceTypeClass, $filterSourceTypeValue, $lastShareID); + }); + } +} diff --git a/build/rector-strict.php b/build/rector-strict.php index f4a9108100ff5..10da8dcbde014 100644 --- a/build/rector-strict.php +++ b/build/rector-strict.php @@ -41,6 +41,7 @@ $nextcloudDir . '/lib/public/Sharing', $nextcloudDir . '/lib/private/Sharing', $nextcloudDir . '/tests/lib/Sharing', + $nextcloudDir . '/apps/sharing', ]) ->withAutoloadPaths([ // ensure rector properly autoload the public interfaces diff --git a/core/shipped.json b/core/shipped.json index 2b1c69928908a..81f734cfeb2cd 100644 --- a/core/shipped.json +++ b/core/shipped.json @@ -40,6 +40,7 @@ "serverinfo", "settings", "sharebymail", + "sharing", "support", "survey_client", "suspicious_login", @@ -116,6 +117,7 @@ "profile", "provisioning_api", "settings", + "sharing", "theming", "twofactor_backupcodes", "viewer", diff --git a/openapi.json b/openapi.json index ededc3929e28c..895290b17b998 100644 --- a/openapi.json +++ b/openapi.json @@ -4190,6 +4190,560 @@ } } }, + "SharingCapabilities": { + "type": "object", + "properties": { + "sharing": { + "type": "object", + "required": [ + "api_versions", + "source_types", + "permission_presets" + ], + "properties": { + "api_versions": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "v1" + ] + } + }, + "source_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SharingSourceType" + } + }, + "permission_presets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SharingPermissionPreset" + } + } + } + } + } + }, + "SharingIcon": { + "anyOf": [ + { + "$ref": "#/components/schemas/SharingIconSVG" + }, + { + "$ref": "#/components/schemas/SharingIconURL" + } + ] + }, + "SharingIconSVG": { + "type": "object", + "required": [ + "svg" + ], + "properties": { + "svg": { + "type": "string", + "description": "An SVG using the currentColor value for dynamic theming.", + "minLength": 1 + } + } + }, + "SharingIconURL": { + "type": "object", + "required": [ + "light", + "dark" + ], + "properties": { + "light": { + "type": "string", + "description": "An absolute URL to an image suitable for light theme.", + "minLength": 1 + }, + "dark": { + "type": "string", + "description": "An absolute URL to an image suitable for dark theme.", + "minLength": 1 + } + } + }, + "SharingPermission": { + "type": "object", + "required": [ + "class", + "source_class", + "display_name", + "hint", + "priority", + "presets", + "enabled" + ], + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "source_class": { + "type": "string", + "nullable": true, + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "hint": { + "type": "string", + "nullable": true, + "minLength": 1 + }, + "priority": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 100 + }, + "presets": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "enabled": { + "type": "boolean" + } + } + }, + "SharingPermissionPreset": { + "type": "object", + "required": [ + "class", + "display_name", + "hint" + ], + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "hint": { + "type": "string", + "nullable": true, + "minLength": 1 + } + } + }, + "SharingProperty": { + "type": "object", + "required": [ + "class", + "display_name", + "hint", + "priority", + "required", + "advanced", + "value" + ], + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "hint": { + "type": "string", + "nullable": true, + "minLength": 1 + }, + "priority": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 100 + }, + "required": { + "type": "boolean" + }, + "advanced": { + "type": "boolean" + }, + "value": { + "type": "string", + "nullable": true + } + } + }, + "SharingPropertyBoolean": { + "allOf": [ + { + "$ref": "#/components/schemas/SharingProperty" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "boolean" + ] + } + } + } + ] + }, + "SharingPropertyDate": { + "allOf": [ + { + "$ref": "#/components/schemas/SharingProperty" + }, + { + "type": "object", + "required": [ + "type", + "min_date", + "max_date" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "date" + ] + }, + "min_date": { + "type": "string", + "nullable": true, + "description": "ISO 8601", + "minLength": 1 + }, + "max_date": { + "type": "string", + "nullable": true, + "description": "ISO 8601", + "minLength": 1 + } + } + } + ] + }, + "SharingPropertyEnum": { + "allOf": [ + { + "$ref": "#/components/schemas/SharingProperty" + }, + { + "type": "object", + "required": [ + "type", + "valid_values" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "enum" + ] + }, + "valid_values": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + } + } + ] + }, + "SharingPropertyPassword": { + "allOf": [ + { + "$ref": "#/components/schemas/SharingProperty" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "password" + ] + } + } + } + ] + }, + "SharingPropertyString": { + "allOf": [ + { + "$ref": "#/components/schemas/SharingProperty" + }, + { + "type": "object", + "required": [ + "type", + "min_length", + "max_length" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "string" + ] + }, + "min_length": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 1 + }, + "max_length": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 1 + } + } + } + ] + }, + "SharingRecipient": { + "type": "object", + "required": [ + "class", + "value", + "instance", + "display_name", + "icon", + "secret", + "initiator" + ], + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string", + "minLength": 1 + }, + "instance": { + "type": "string", + "nullable": true, + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "icon": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/SharingIcon" + } + ] + }, + "secret": { + "type": "object", + "required": [ + "updatable" + ], + "properties": { + "updatable": { + "type": "boolean" + }, + "value": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string", + "minLength": 1 + } + } + }, + "initiator": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/SharingUser" + } + ] + } + } + }, + "SharingShare": { + "type": "object", + "required": [ + "id", + "owner", + "last_updated", + "state", + "sources", + "recipients", + "properties", + "permissions", + "permission_preset" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "owner": { + "$ref": "#/components/schemas/SharingUser" + }, + "last_updated": { + "type": "integer", + "format": "int64", + "description": "Unix time in milliseconds", + "minimum": 0 + }, + "state": { + "$ref": "#/components/schemas/SharingState" + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SharingSource" + } + }, + "recipients": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SharingRecipient" + } + }, + "properties": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/SharingPropertyDate" + }, + { + "$ref": "#/components/schemas/SharingPropertyEnum" + }, + { + "$ref": "#/components/schemas/SharingPropertyBoolean" + }, + { + "$ref": "#/components/schemas/SharingPropertyPassword" + }, + { + "$ref": "#/components/schemas/SharingPropertyString" + } + ] + } + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SharingPermission" + } + }, + "permission_preset": { + "type": "string", + "nullable": true, + "minLength": 1 + } + } + }, + "SharingSource": { + "type": "object", + "required": [ + "class", + "value", + "display_name", + "icon" + ], + "properties": { + "class": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string", + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "icon": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/SharingIcon" + } + ] + } + } + }, + "SharingSourceType": { + "type": "object", + "required": [ + "class" + ], + "properties": { + "class": { + "type": "string", + "minLength": 1 + } + } + }, + "SharingState": { + "type": "string", + "enum": [ + "active", + "draft", + "deleted" + ] + }, + "SharingUser": { + "type": "object", + "required": [ + "user_id", + "instance", + "display_name", + "icon" + ], + "properties": { + "user_id": { + "type": "string", + "minLength": 1 + }, + "instance": { + "type": "string", + "nullable": true, + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "icon": { + "$ref": "#/components/schemas/SharingIcon" + } + } + }, "SystemtagsCapabilities": { "type": "object", "required": [ @@ -7344,6 +7898,9 @@ { "$ref": "#/components/schemas/SharebymailCapabilities" }, + { + "$ref": "#/components/schemas/SharingCapabilities" + }, { "$ref": "#/components/schemas/SystemtagsCapabilities" }, @@ -35940,6 +36497,2635 @@ } } }, + "/ocs/v2.php/apps/sharing/api/v1/recipients": { + "get": { + "operationId": "sharing-api_v1-search-recipients", + "summary": "Search for recpients that can be added to a share.", + "tags": [ + "sharing/api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "recipientTypeClasses[]", + "in": "query", + "description": "Type class of recipients to filter by", + "schema": { + "type": "array", + "nullable": true, + "items": { + "type": "string", + "minLength": 1 + } + } + }, + { + "name": "query", + "in": "query", + "description": "The query to search for", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "The maximum number of participants", + "schema": { + "type": "integer", + "format": "int64", + "default": 10, + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "offset", + "in": "query", + "description": "The offset of the participants", + "schema": { + "type": "integer", + "format": "int64", + "default": 0, + "minimum": 0 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Recipients returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SharingRecipient" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid recipient search parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/secret": { + "get": { + "operationId": "sharing-api_v1-generate-secret", + "summary": "Generate a new secret.", + "tags": [ + "sharing/api_v1" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Generated secret returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share": { + "post": { + "operationId": "sharing-api_v1-create-share", + "summary": "Create a new share.", + "tags": [ + "sharing/api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "Share created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SharingShare" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}/state": { + "put": { + "operationId": "sharing-api_v1-update-share-state", + "summary": "Update the state of a share.", + "tags": [ + "sharing/api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "$ref": "#/components/schemas/SharingState", + "description": "New state of the share" + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share state updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SharingShare" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid share state", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Updating the share state is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}/source": { + "post": { + "operationId": "sharing-api_v1-add-share-source", + "summary": "Add a new source to a share.", + "tags": [ + "sharing/api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "class", + "value" + ], + "properties": { + "class": { + "type": "string", + "description": "Type class of the source", + "minLength": 1 + }, + "value": { + "type": "string", + "description": "Value of the source", + "minLength": 1 + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share source added successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SharingShare" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid share source", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Adding the share source is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "sharing-api_v1-remove-share-source", + "summary": "Remove an existing source from a share.", + "tags": [ + "sharing/api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "class", + "in": "query", + "description": "Type class of the source", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "value", + "in": "query", + "description": "Value of the source", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share source removed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SharingShare" + } + } + } + } + } + } + } + }, + "403": { + "description": "Removing the share source is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}/recipient": { + "post": { + "operationId": "sharing-api_v1-add-share-recipient", + "summary": "Add a new recipient to a share.", + "tags": [ + "sharing/api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "class", + "value" + ], + "properties": { + "class": { + "type": "string", + "description": "Type class of the recipient", + "minLength": 1 + }, + "value": { + "type": "string", + "description": "Value of the recipient", + "minLength": 1 + }, + "instance": { + "type": "string", + "nullable": true, + "description": "Instance of the recipient", + "minLength": 1 + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share recipient added successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SharingShare" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid share recipient", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Adding the share recipient is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "sharing-api_v1-remove-share-recipient", + "summary": "Remove an existing recipient from a share.", + "tags": [ + "sharing/api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "class", + "in": "query", + "description": "Type class of the recipient", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "value", + "in": "query", + "description": "Value of the recipient", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "instance", + "in": "query", + "description": "Instance of the recipient", + "schema": { + "type": "string", + "nullable": true, + "minLength": 1 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share recipient removed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SharingShare" + } + } + } + } + } + } + } + }, + "403": { + "description": "Removing the share recipient is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}/recipient/secret": { + "put": { + "operationId": "sharing-api_v1-update-share-recipient-secret", + "summary": "Update the scecret of a recipient.", + "tags": [ + "sharing/api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "class", + "value", + "secret" + ], + "properties": { + "class": { + "type": "string", + "description": "Type class of the recipient", + "minLength": 1 + }, + "value": { + "type": "string", + "description": "Value of the recipient", + "minLength": 1 + }, + "instance": { + "type": "string", + "nullable": true, + "description": "Instance of the recipient", + "minLength": 1 + }, + "secret": { + "type": "string", + "description": "Secret of the recipient", + "minLength": 1 + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share recipient secret updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SharingShare" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid secret", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Updating the share recipient secret is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}/property": { + "put": { + "operationId": "sharing-api_v1-update-share-property", + "summary": "Update a property of a share.", + "tags": [ + "sharing/api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "class" + ], + "properties": { + "class": { + "type": "string", + "description": "Type class of the property", + "minLength": 1 + }, + "value": { + "type": "string", + "nullable": true, + "description": "Value of the property" + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share property updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SharingShare" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid share property", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Updating the share property is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}/permission": { + "put": { + "operationId": "sharing-api_v1-update-share-permission", + "summary": "Update a permission of a share.", + "tags": [ + "sharing/api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "class", + "enabled" + ], + "properties": { + "class": { + "type": "string", + "description": "Type class of the permission", + "minLength": 1 + }, + "enabled": { + "type": "boolean", + "description": "Enabled state of the permission" + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share permission updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SharingShare" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid share permission", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Updating the share permission is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}/permission/preset": { + "put": { + "operationId": "sharing-api_v1-select-share-permission-preset", + "summary": "Select a permission preset for a share.", + "tags": [ + "sharing/api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "permissionPresetClass" + ], + "properties": { + "permissionPresetClass": { + "type": "string", + "description": "New permission preset of the share", + "minLength": 1 + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share permission preset selected successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SharingShare" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid share permission preset", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "403": { + "description": "Selecting the share permission preset is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/share/{id}": { + "delete": { + "operationId": "sharing-api_v1-delete-share", + "summary": "Delete a share.", + "tags": [ + "sharing/api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "204": { + "description": "Share deleted" + }, + "403": { + "description": "Deleting the share is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "sharing-api_v1-get-share", + "summary": "Get a share.", + "tags": [ + "sharing/api_v1" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secret": { + "type": "string", + "nullable": true, + "default": null, + "description": "Secret of the share" + }, + "arguments": { + "type": "object", + "default": {}, + "description": "Arguments for accessing the share", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the share", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Share returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SharingShare" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid arguments", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "404": { + "description": "Share not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/sharing/api/v1/shares": { + "get": { + "operationId": "sharing-api_v1-get-shares", + "summary": "Get multiple shares.", + "tags": [ + "sharing/api_v1" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "filterSourceTypeClass", + "in": "query", + "description": "Source type class to filter by.", + "schema": { + "type": "string", + "nullable": true, + "minLength": 1 + } + }, + { + "name": "filterSourceTypeValue", + "in": "query", + "description": "Source type value to filter by.", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "lastShareID", + "in": "query", + "description": "The ID of the previous share. This is used as an offset and only shares with higher IDs are returned.", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "limit", + "in": "query", + "description": "The number of shares to return.", + "schema": { + "type": "integer", + "format": "int64", + "default": 100, + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Shares returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SharingShare" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "string" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/index.php/apps/theming/theme/{themeId}.css": { "get": { "operationId": "theming-theming-get-theme-stylesheet", diff --git a/psalm-strict.xml b/psalm-strict.xml index 5ead4b4b4684c..f546968904af6 100644 --- a/psalm-strict.xml +++ b/psalm-strict.xml @@ -48,6 +48,7 @@ + diff --git a/psalm.xml b/psalm.xml index 64238f00f6d6b..6e04adfaade86 100644 --- a/psalm.xml +++ b/psalm.xml @@ -46,6 +46,7 @@ + From 8089353e633a51de7135e9119a1884340f5d5ce3 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Mon, 4 May 2026 13:44:25 +0200 Subject: [PATCH 3/5] feat(core/Sharing): Init Signed-off-by: provokateurin --- build/psalm-baseline.xml | 23 +++ build/rector-strict.php | 2 + core/AppInfo/Application.php | 52 +++++ .../Permission/EditSharePermissionPreset.php | 26 +++ .../Permission/ReshareSharePermissionType.php | 45 +++++ .../Permission/ViewSharePermissionPreset.php | 27 +++ .../ExpirationDateSharePropertyType.php | 128 +++++++++++++ .../Property/LabelSharePropertyType.php | 56 ++++++ .../Property/NoteSharePropertyType.php | 56 ++++++ .../Property/PasswordSharePropertyType.php | 80 ++++++++ .../Recipient/EmailShareRecipientType.php | 72 +++++++ .../Recipient/GroupShareRecipientType.php | 111 +++++++++++ .../Recipient/TeamShareRecipientType.php | 107 +++++++++++ .../Recipient/TokenShareRecipientType.php | 71 +++++++ .../Recipient/UserShareRecipientType.php | 108 +++++++++++ lib/composer/composer/autoload_classmap.php | 12 ++ lib/composer/composer/autoload_static.php | 12 ++ psalm-strict.xml | 4 + .../ExpirationDateSharePropertyTypeTest.php | 167 ++++++++++++++++ .../PasswordSharePropertyTypeTest.php | 73 +++++++ .../Recipient/EmailShareRecipientTypeTest.php | 102 ++++++++++ .../Recipient/GroupShareRecipientTypeTest.php | 159 ++++++++++++++++ .../Recipient/TeamShareRecipientTypeTest.php | 179 ++++++++++++++++++ .../Recipient/TokenShareRecipientTypeTest.php | 33 ++++ .../Recipient/UserShareRecipientTypeTest.php | 159 ++++++++++++++++ 25 files changed, 1864 insertions(+) create mode 100644 core/Sharing/Permission/EditSharePermissionPreset.php create mode 100644 core/Sharing/Permission/ReshareSharePermissionType.php create mode 100644 core/Sharing/Permission/ViewSharePermissionPreset.php create mode 100644 core/Sharing/Property/ExpirationDateSharePropertyType.php create mode 100644 core/Sharing/Property/LabelSharePropertyType.php create mode 100644 core/Sharing/Property/NoteSharePropertyType.php create mode 100644 core/Sharing/Property/PasswordSharePropertyType.php create mode 100644 core/Sharing/Recipient/EmailShareRecipientType.php create mode 100644 core/Sharing/Recipient/GroupShareRecipientType.php create mode 100644 core/Sharing/Recipient/TeamShareRecipientType.php create mode 100644 core/Sharing/Recipient/TokenShareRecipientType.php create mode 100644 core/Sharing/Recipient/UserShareRecipientType.php create mode 100644 tests/Core/Sharing/Property/ExpirationDateSharePropertyTypeTest.php create mode 100644 tests/Core/Sharing/Property/PasswordSharePropertyTypeTest.php create mode 100644 tests/Core/Sharing/Recipient/EmailShareRecipientTypeTest.php create mode 100644 tests/Core/Sharing/Recipient/GroupShareRecipientTypeTest.php create mode 100644 tests/Core/Sharing/Recipient/TeamShareRecipientTypeTest.php create mode 100644 tests/Core/Sharing/Recipient/TokenShareRecipientTypeTest.php create mode 100644 tests/Core/Sharing/Recipient/UserShareRecipientTypeTest.php diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index 02983e913bad4..9a61941327488 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -3287,6 +3287,24 @@ + + + + + + + + + + + + + + + + + + @@ -4267,6 +4285,11 @@ + + + + + diff --git a/build/rector-strict.php b/build/rector-strict.php index 10da8dcbde014..89bf909e5ed5e 100644 --- a/build/rector-strict.php +++ b/build/rector-strict.php @@ -42,6 +42,8 @@ $nextcloudDir . '/lib/private/Sharing', $nextcloudDir . '/tests/lib/Sharing', $nextcloudDir . '/apps/sharing', + $nextcloudDir . '/core/Sharing', + $nextcloudDir . '/tests/Core/Sharing', ]) ->withAutoloadPaths([ // ensure rector properly autoload the public interfaces diff --git a/core/AppInfo/Application.php b/core/AppInfo/Application.php index e02fefb4636bd..b2a80ce39719e 100644 --- a/core/AppInfo/Application.php +++ b/core/AppInfo/Application.php @@ -24,6 +24,18 @@ use OC\Core\Listener\PasswordUpdatedListener; use OC\Core\Listener\RestrictInteractionListener; use OC\Core\Notification\CoreNotifier; +use OC\Core\Sharing\Permission\EditSharePermissionPreset; +use OC\Core\Sharing\Permission\ReshareSharePermissionType; +use OC\Core\Sharing\Permission\ViewSharePermissionPreset; +use OC\Core\Sharing\Property\ExpirationDateSharePropertyType; +use OC\Core\Sharing\Property\LabelSharePropertyType; +use OC\Core\Sharing\Property\NoteSharePropertyType; +use OC\Core\Sharing\Property\PasswordSharePropertyType; +use OC\Core\Sharing\Recipient\EmailShareRecipientType; +use OC\Core\Sharing\Recipient\GroupShareRecipientType; +use OC\Core\Sharing\Recipient\TeamShareRecipientType; +use OC\Core\Sharing\Recipient\TokenShareRecipientType; +use OC\Core\Sharing\Recipient\UserShareRecipientType; use OC\OCM\OCMDiscoveryHandler; use OC\OCM\OCMJwksHandler; use OC\TagManager; @@ -35,11 +47,14 @@ use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCP\DB\Events\AddMissingIndicesEvent; use OCP\DB\Events\AddMissingPrimaryKeyEvent; +use OCP\IAppConfig; use OCP\INavigationManager; use OCP\Interaction\RestrictInteractionEvent; use OCP\IURLGenerator; use OCP\IUserSession; use OCP\L10N\IFactory; +use OCP\Server; +use OCP\Sharing\ISharingRegistry; use OCP\User\Events\BeforeUserDeletedEvent; use OCP\User\Events\PasswordUpdatedEvent; use OCP\User\Events\UserDeletedEvent; @@ -100,6 +115,43 @@ public function register(IRegistrationContext $context): void { $context->registerCapability(Capabilities::class); $context->registerEventListener(RestrictInteractionEvent::class, RestrictInteractionListener::class); + + $registry = Server::get(ISharingRegistry::class); + + $registry->registerRecipientType(new EmailShareRecipientType()); + $registry->registerRecipientType(Server::get(GroupShareRecipientType::class)); + $registry->registerRecipientType(Server::get(TeamShareRecipientType::class)); + $registry->registerRecipientType(new TokenShareRecipientType()); + $registry->registerRecipientType(Server::get(UserShareRecipientType::class)); + + $registry->registerPropertyType(new ExpirationDateSharePropertyType()); + $registry->markPropertyTypeCompatibleWithRecipientType(ExpirationDateSharePropertyType::class, EmailShareRecipientType::class); + $registry->markPropertyTypeCompatibleWithRecipientType(ExpirationDateSharePropertyType::class, GroupShareRecipientType::class); + $registry->markPropertyTypeCompatibleWithRecipientType(ExpirationDateSharePropertyType::class, TeamShareRecipientType::class); + $registry->markPropertyTypeCompatibleWithRecipientType(ExpirationDateSharePropertyType::class, TokenShareRecipientType::class); + $registry->markPropertyTypeCompatibleWithRecipientType(ExpirationDateSharePropertyType::class, UserShareRecipientType::class); + + $registry->registerPropertyType(new LabelSharePropertyType()); + $registry->markPropertyTypeCompatibleWithRecipientType(LabelSharePropertyType::class, TokenShareRecipientType::class); + + $registry->registerPropertyType(new NoteSharePropertyType()); + $registry->markPropertyTypeCompatibleWithRecipientType(NoteSharePropertyType::class, EmailShareRecipientType::class); + $registry->markPropertyTypeCompatibleWithRecipientType(NoteSharePropertyType::class, GroupShareRecipientType::class); + $registry->markPropertyTypeCompatibleWithRecipientType(NoteSharePropertyType::class, TeamShareRecipientType::class); + $registry->markPropertyTypeCompatibleWithRecipientType(NoteSharePropertyType::class, TokenShareRecipientType::class); + $registry->markPropertyTypeCompatibleWithRecipientType(NoteSharePropertyType::class, UserShareRecipientType::class); + + $registry->registerPropertyType(new PasswordSharePropertyType()); + $registry->markPropertyTypeCompatibleWithRecipientType(PasswordSharePropertyType::class, EmailShareRecipientType::class); + $registry->markPropertyTypeCompatibleWithRecipientType(PasswordSharePropertyType::class, TokenShareRecipientType::class); + + $registry->registerPermissionPreset(new ViewSharePermissionPreset()); + $registry->registerPermissionPreset(new EditSharePermissionPreset()); + + $registry->registerPermissionType(null, new ReshareSharePermissionType()); + if (!Server::get(IAppConfig::class)->getValueBool('files_sharing', \OCA\Files_Sharing\Config\ConfigLexicon::EXCLUDE_RESHARE_FROM_EDIT)) { + $registry->markPermissionTypeCompatibleWithPermissionPreset(ReshareSharePermissionType::class, EditSharePermissionPreset::class); + } } #[\Override] diff --git a/core/Sharing/Permission/EditSharePermissionPreset.php b/core/Sharing/Permission/EditSharePermissionPreset.php new file mode 100644 index 0000000000000..2ae47c28e729d --- /dev/null +++ b/core/Sharing/Permission/EditSharePermissionPreset.php @@ -0,0 +1,26 @@ +get(Application::APP_ID)->t('Can edit'); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + return null; + } +} diff --git a/core/Sharing/Permission/ReshareSharePermissionType.php b/core/Sharing/Permission/ReshareSharePermissionType.php new file mode 100644 index 0000000000000..930e8d413dcae --- /dev/null +++ b/core/Sharing/Permission/ReshareSharePermissionType.php @@ -0,0 +1,45 @@ +appConfig ??= Server::get(IAppConfig::class); + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + return $l10nFactory->get(Application::APP_ID)->t('Share with others'); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + return null; + } + + #[\Override] + public function getPriority(): int { + return 90; + } + + #[\Override] + public function isEnabledByDefault(): bool { + return ($this->getAppConfig()->getValueInt(Application::APP_ID, 'shareapi_default_permissions') & Constants::PERMISSION_SHARE) === Constants::PERMISSION_SHARE; + } +} diff --git a/core/Sharing/Permission/ViewSharePermissionPreset.php b/core/Sharing/Permission/ViewSharePermissionPreset.php new file mode 100644 index 0000000000000..ffd3693c15e4a --- /dev/null +++ b/core/Sharing/Permission/ViewSharePermissionPreset.php @@ -0,0 +1,27 @@ +get(Application::APP_ID)->t('Can view'); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + return null; + } +} diff --git a/core/Sharing/Property/ExpirationDateSharePropertyType.php b/core/Sharing/Property/ExpirationDateSharePropertyType.php new file mode 100644 index 0000000000000..525333c7ba040 --- /dev/null +++ b/core/Sharing/Property/ExpirationDateSharePropertyType.php @@ -0,0 +1,128 @@ +legacyManager ??= Server::get(IManager::class); + } + + public function __construct() { + $this->now = new DateTimeImmutable(); + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + return $l10nFactory->get(Application::APP_ID)->t('Expiration date'); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + return null; + } + + #[\Override] + public function getPriority(): int { + return 70; + } + + #[\Override] + public function isAdvanced(): bool { + return true; + } + + #[\Override] + public function isRequired(): bool { + if ($this->getLegacyManager()->shareApiLinkDefaultExpireDateEnforced()) { + return true; + } + + if ($this->getLegacyManager()->shareApiRemoteDefaultExpireDateEnforced()) { + return true; + } + + return $this->getLegacyManager()->shareApiInternalDefaultExpireDateEnforced(); + } + + #[\Override] + public function getDefaultValue(): ?string { + return $this->getMaxExpirationDate()?->format(DateTimeInterface::ATOM); + } + + #[\Override] + public function getMinDate(): \DateTimeImmutable { + // Ensure the expiration date is in the future. + return $this->now->add(new DateInterval('PT5M')); + } + + #[\Override] + public function getMaxDate(): ?DateTimeImmutable { + if ($this->isRequired()) { + // Allow some time to pass between the user getting the max date and saving the date, as the time will shift in between. + return $this->getMaxExpirationDate()?->add(new DateInterval('PT5M')); + } + + return null; + } + + private function getMaxExpirationDate(): ?DateTimeImmutable { + // We do not have any distinction between link/remote/internal, so we just apply the lowest expiration days count to be safe. + $days = INF; + if ($this->getLegacyManager()->shareApiLinkDefaultExpireDate()) { + $days = min($days, $this->getLegacyManager()->shareApiLinkDefaultExpireDays()); + } + + if ($this->getLegacyManager()->shareApiRemoteDefaultExpireDate()) { + $days = min($days, $this->getLegacyManager()->shareApiRemoteDefaultExpireDays()); + } + + if ($this->getLegacyManager()->shareApiInternalDefaultExpireDate()) { + $days = min($days, $this->getLegacyManager()->shareApiInternalDefaultExpireDays()); + } + + if ($days !== INF) { + return $this->now->add(new DateInterval('P' . $days . 'D')); + } + + return null; + } + + #[\Override] + public function isFiltered(ShareAccessContext $accessContext, Share $share): bool { + if (($property = $share->properties[self::class] ?? null) !== null && $property->value !== null) { + $date = DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, $property->value); + if ($date === false) { + throw new RuntimeException('Invalid date.'); + } + + return $this->now->diff($date)->invert === 1; + } + + return false; + } +} diff --git a/core/Sharing/Property/LabelSharePropertyType.php b/core/Sharing/Property/LabelSharePropertyType.php new file mode 100644 index 0000000000000..699262ec52d19 --- /dev/null +++ b/core/Sharing/Property/LabelSharePropertyType.php @@ -0,0 +1,56 @@ +get(Application::APP_ID)->t('Label'); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + return null; + } + + #[\Override] + public function getPriority(): int { + return 80; + } + + #[\Override] + public function isAdvanced(): bool { + return true; + } + + #[\Override] + public function isRequired(): bool { + return false; + } + + #[\Override] + public function getDefaultValue(): ?string { + return null; + } + + #[\Override] + public function getMinLength(): int { + return 3; + } + + #[\Override] + public function getMaxLength(): int { + return 100; + } +} diff --git a/core/Sharing/Property/NoteSharePropertyType.php b/core/Sharing/Property/NoteSharePropertyType.php new file mode 100644 index 0000000000000..685c000d1777e --- /dev/null +++ b/core/Sharing/Property/NoteSharePropertyType.php @@ -0,0 +1,56 @@ +get(Application::APP_ID)->t('Note to recipients'); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + return null; + } + + #[\Override] + public function getPriority(): int { + return 90; + } + + #[\Override] + public function isAdvanced(): bool { + return false; + } + + #[\Override] + public function isRequired(): bool { + return false; + } + + #[\Override] + public function getDefaultValue(): ?string { + return null; + } + + #[\Override] + public function getMinLength(): ?int { + return null; + } + + #[\Override] + public function getMaxLength(): int { + return 1000; + } +} diff --git a/core/Sharing/Property/PasswordSharePropertyType.php b/core/Sharing/Property/PasswordSharePropertyType.php new file mode 100644 index 0000000000000..6932e7f9a0953 --- /dev/null +++ b/core/Sharing/Property/PasswordSharePropertyType.php @@ -0,0 +1,80 @@ +legacyManager ??= Server::get(IManager::class); + } + + private function getHasher(): IHasher { + return $this->hasher ??= Server::get(IHasher::class); + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + return $l10nFactory->get(Application::APP_ID)->t('Password'); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + return null; + } + + #[\Override] + public function getPriority(): int { + return 60; + } + + #[\Override] + public function isAdvanced(): bool { + return true; + } + + #[\Override] + public function isRequired(): bool { + // TODO: Enable group memberships check based on the owner. + return $this->getLegacyManager()->shareApiLinkEnforcePassword(false); + } + + #[\Override] + public function getDefaultValue(): ?string { + return null; + } + + #[\Override] + public function isFiltered(ShareAccessContext $accessContext, Share $share): bool { + $argument = $accessContext->arguments[self::class] ?? null; + if (!is_string($argument)) { + return true; + } + + if (($property = $share->properties[self::class] ?? null) !== null && $property->value !== null) { + // TODO: Check if the hash has to be updated and save it. + return !$this->getHasher()->verify($argument, $property->value); + } + + return false; + } +} diff --git a/core/Sharing/Recipient/EmailShareRecipientType.php b/core/Sharing/Recipient/EmailShareRecipientType.php new file mode 100644 index 0000000000000..42f1c5e97edbf --- /dev/null +++ b/core/Sharing/Recipient/EmailShareRecipientType.php @@ -0,0 +1,72 @@ +emailValidator ??= Server::get(IEmailValidator::class); + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + return $l10nFactory->get(Application::APP_ID)->t('Email'); + } + + #[\Override] + public function validateRecipient(string $recipient): bool { + return $this->getEmailValidator()->isValid($recipient); + } + + #[\Override] + public function getRecipients(?IUser $currentUser, mixed $arguments): array { + return []; + } + + #[\Override] + public function getRecipientDisplayName(string $recipient): string { + return $recipient; + } + + #[\Override] + public function getRecipientIcon(string $recipient): null|ShareIconSVG|ShareIconURL { + return null; + } + + #[\Override] + public function getRecipientInteractionReceiver(string $recipient): InteractionReceiver { + return new EmailReceiver($recipient); + } + + #[\Override] + public function getCollaboratorType(): int { + return IShare::TYPE_EMAIL; + } + + #[\Override] + public function getCollaboratorKey(): string { + return 'emails'; + } +} diff --git a/core/Sharing/Recipient/GroupShareRecipientType.php b/core/Sharing/Recipient/GroupShareRecipientType.php new file mode 100644 index 0000000000000..5e46745cced98 --- /dev/null +++ b/core/Sharing/Recipient/GroupShareRecipientType.php @@ -0,0 +1,111 @@ + + */ +final class GroupShareRecipientType extends AShareRecipientTypeSearchCollaborator implements IEventListener { + private ?IGroupManager $groupManager = null; + + public function __construct( + IEventDispatcher $eventDispatcher, + private readonly IDBConnection $dbConnection, + private readonly ISharingManager $manager, + ) { + $eventDispatcher->addServiceListener(GroupDeletedEvent::class, self::class); + } + + private function getGroupManager(): IGroupManager { + return $this->groupManager ??= Server::get(IGroupManager::class); + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + return $l10nFactory->get(Application::APP_ID)->t('Group'); + } + + #[\Override] + public function validateRecipient(string $recipient): bool { + return $this->getGroupManager()->groupExists($recipient); + } + + #[\Override] + public function getRecipients(?IUser $currentUser, mixed $arguments): array { + if (!$currentUser instanceof IUser) { + return []; + } + + return $this->getGroupManager()->getUserGroupIds($currentUser); + } + + #[\Override] + public function getRecipientDisplayName(string $recipient): ?string { + $displayName = $this->getGroupManager()->getDisplayName($recipient); + if ($displayName === '') { + return null; + } + + return $displayName; + } + + #[\Override] + public function getRecipientIcon(string $recipient): null|ShareIconSVG|ShareIconURL { + return null; + } + + #[\Override] + public function getRecipientInteractionReceiver(string $recipient): InteractionReceiver { + return new GroupReceiver($recipient); + } + + #[\Override] + public function getCollaboratorType(): int { + return IShare::TYPE_GROUP; + } + + #[\Override] + public function getCollaboratorKey(): string { + return 'groups'; + } + + #[\Override] + public function handle(Event $event): void { + try { + $this->dbConnection->beginTransaction(); + $this->manager->onRecipientDeleted(new ShareAccessContext(overrideChecks: true), new ShareRecipient(self::class, $event->getGroup()->getGID(), null)); + $this->dbConnection->commit(); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } +} diff --git a/core/Sharing/Recipient/TeamShareRecipientType.php b/core/Sharing/Recipient/TeamShareRecipientType.php new file mode 100644 index 0000000000000..229c2a6f2a6e7 --- /dev/null +++ b/core/Sharing/Recipient/TeamShareRecipientType.php @@ -0,0 +1,107 @@ + + */ +final class TeamShareRecipientType extends AShareRecipientTypeSearchCollaborator implements IEventListener { + private ?ITeamManager $teamManager = null; + + public function __construct( + IEventDispatcher $eventDispatcher, + private readonly IDBConnection $dbConnection, + private readonly ISharingManager $manager, + ) { + $eventDispatcher->addServiceListener(DestroyingCircleEvent::class, self::class); + } + + private function getTeamManager(): ITeamManager { + return $this->teamManager ??= Server::get(ITeamManager::class); + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + return $l10nFactory->get(Application::APP_ID)->t('Team'); + } + + #[\Override] + public function validateRecipient(string $recipient): bool { + return $this->getTeamManager()->getTeam($recipient) instanceof Team; + } + + #[\Override] + public function getRecipients(?IUser $currentUser, mixed $arguments): array { + if (!$currentUser instanceof IUser) { + return []; + } + + return array_map(static fn (Team $team): string => $team->getId(), $this->getTeamManager()->getTeamsForUser($currentUser->getUID())); + } + + #[\Override] + public function getRecipientDisplayName(string $recipient): ?string { + return $this->getTeamManager()->getTeam($recipient)?->getDisplayName(); + } + + #[\Override] + public function getRecipientIcon(string $recipient): null|ShareIconSVG|ShareIconURL { + return null; + } + + #[\Override] + public function getRecipientInteractionReceiver(string $recipient): InteractionReceiver { + return new CircleReceiver($recipient); + } + + #[\Override] + public function getCollaboratorType(): int { + return IShare::TYPE_CIRCLE; + } + + #[\Override] + public function getCollaboratorKey(): string { + return 'circles'; + } + + #[\Override] + public function handle(Event $event): void { + try { + $this->dbConnection->beginTransaction(); + $this->manager->onRecipientDeleted(new ShareAccessContext(overrideChecks: true), new ShareRecipient(self::class, $event->getCircle()->getSingleId(), null)); + $this->dbConnection->commit(); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } +} diff --git a/core/Sharing/Recipient/TokenShareRecipientType.php b/core/Sharing/Recipient/TokenShareRecipientType.php new file mode 100644 index 0000000000000..1a669e1318f0f --- /dev/null +++ b/core/Sharing/Recipient/TokenShareRecipientType.php @@ -0,0 +1,71 @@ +legacyManager ??= Server::get(IManager::class); + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + return $l10nFactory->get(Application::APP_ID)->t('Public link'); + } + + #[\Override] + public function validateRecipient(string $recipient): bool { + return strlen($recipient) >= 32 && strlen($recipient) <= 255; + } + + #[\Override] + public function getRecipients(?IUser $currentUser, mixed $arguments): array { + return []; + } + + #[\Override] + public function getRecipientDisplayName(string $recipient): ?string { + return null; + } + + #[\Override] + public function getRecipientIcon(string $recipient): null|ShareIconSVG|ShareIconURL { + return null; + } + + #[\Override] + public function getRecipientInteractionReceiver(string $recipient): InteractionReceiver { + return new LinkReceiver(); + } + + #[\Override] + public function isSecretPublic(string $recipient): bool { + return true; + } + + #[\Override] + public function isSecretUpdatable(string $recipient): bool { + return $this->getLegacyManager()->allowCustomTokens(); + } +} diff --git a/core/Sharing/Recipient/UserShareRecipientType.php b/core/Sharing/Recipient/UserShareRecipientType.php new file mode 100644 index 0000000000000..abeff761a9906 --- /dev/null +++ b/core/Sharing/Recipient/UserShareRecipientType.php @@ -0,0 +1,108 @@ + + */ +final class UserShareRecipientType extends AShareRecipientTypeSearchCollaborator implements IEventListener { + private ?IUserManager $userManager = null; + + public function __construct( + IEventDispatcher $eventDispatcher, + private readonly IDBConnection $dbConnection, + private readonly ISharingManager $manager, + ) { + $eventDispatcher->addServiceListener(UserDeletedEvent::class, self::class); + } + + private function getUserManager(): IUserManager { + return $this->userManager ??= Server::get(IUserManager::class); + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + return $l10nFactory->get(Application::APP_ID)->t('User'); + } + + #[\Override] + public function validateRecipient(string $recipient): bool { + return $this->getUserManager()->userExists($recipient); + } + + #[\Override] + public function getRecipients(?IUser $currentUser, mixed $arguments): array { + if (!$currentUser instanceof IUser) { + return []; + } + + return [$currentUser->getUID()]; + } + + #[\Override] + public function getRecipientDisplayName(string $recipient): ?string { + return $this->getUserManager()->getDisplayName($recipient); + } + + #[\Override] + public function getRecipientIcon(string $recipient): ShareIconURL { + return new ShareIconURL( + $this->getUserManager()->getAvatarUrlLight($recipient, 64), + $this->getUserManager()->getAvatarUrlDark($recipient, 64), + ); + } + + #[\Override] + public function getRecipientInteractionReceiver(string $recipient): InteractionReceiver { + return new UserReceiver($recipient); + } + + #[\Override] + public function getCollaboratorType(): int { + return IShare::TYPE_USER; + } + + #[\Override] + public function getCollaboratorKey(): string { + return 'users'; + } + + #[\Override] + public function handle(Event $event): void { + try { + $this->dbConnection->beginTransaction(); + $this->manager->onRecipientDeleted(new ShareAccessContext(overrideChecks: true), new ShareRecipient(self::class, $event->getUser()->getUID(), null)); + $this->dbConnection->commit(); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } +} diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index a55135b28d737..06e2bba45eb35 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1685,6 +1685,18 @@ 'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php', 'OC\\Core\\Service\\CronService' => $baseDir . '/core/Service/CronService.php', 'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php', + 'OC\\Core\\Sharing\\Permission\\EditSharePermissionPreset' => $baseDir . '/core/Sharing/Permission/EditSharePermissionPreset.php', + 'OC\\Core\\Sharing\\Permission\\ReshareSharePermissionType' => $baseDir . '/core/Sharing/Permission/ReshareSharePermissionType.php', + 'OC\\Core\\Sharing\\Permission\\ViewSharePermissionPreset' => $baseDir . '/core/Sharing/Permission/ViewSharePermissionPreset.php', + 'OC\\Core\\Sharing\\Property\\ExpirationDateSharePropertyType' => $baseDir . '/core/Sharing/Property/ExpirationDateSharePropertyType.php', + 'OC\\Core\\Sharing\\Property\\LabelSharePropertyType' => $baseDir . '/core/Sharing/Property/LabelSharePropertyType.php', + 'OC\\Core\\Sharing\\Property\\NoteSharePropertyType' => $baseDir . '/core/Sharing/Property/NoteSharePropertyType.php', + 'OC\\Core\\Sharing\\Property\\PasswordSharePropertyType' => $baseDir . '/core/Sharing/Property/PasswordSharePropertyType.php', + 'OC\\Core\\Sharing\\Recipient\\EmailShareRecipientType' => $baseDir . '/core/Sharing/Recipient/EmailShareRecipientType.php', + 'OC\\Core\\Sharing\\Recipient\\GroupShareRecipientType' => $baseDir . '/core/Sharing/Recipient/GroupShareRecipientType.php', + 'OC\\Core\\Sharing\\Recipient\\TeamShareRecipientType' => $baseDir . '/core/Sharing/Recipient/TeamShareRecipientType.php', + 'OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType' => $baseDir . '/core/Sharing/Recipient/TokenShareRecipientType.php', + 'OC\\Core\\Sharing\\Recipient\\UserShareRecipientType' => $baseDir . '/core/Sharing/Recipient/UserShareRecipientType.php', 'OC\\DB\\Adapter' => $baseDir . '/lib/private/DB/Adapter.php', 'OC\\DB\\AdapterMySQL' => $baseDir . '/lib/private/DB/AdapterMySQL.php', 'OC\\DB\\AdapterOCI8' => $baseDir . '/lib/private/DB/AdapterOCI8.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 5da8d4b59e855..04b041895cca8 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1726,6 +1726,18 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php', 'OC\\Core\\Service\\CronService' => __DIR__ . '/../../..' . '/core/Service/CronService.php', 'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php', + 'OC\\Core\\Sharing\\Permission\\EditSharePermissionPreset' => __DIR__ . '/../../..' . '/core/Sharing/Permission/EditSharePermissionPreset.php', + 'OC\\Core\\Sharing\\Permission\\ReshareSharePermissionType' => __DIR__ . '/../../..' . '/core/Sharing/Permission/ReshareSharePermissionType.php', + 'OC\\Core\\Sharing\\Permission\\ViewSharePermissionPreset' => __DIR__ . '/../../..' . '/core/Sharing/Permission/ViewSharePermissionPreset.php', + 'OC\\Core\\Sharing\\Property\\ExpirationDateSharePropertyType' => __DIR__ . '/../../..' . '/core/Sharing/Property/ExpirationDateSharePropertyType.php', + 'OC\\Core\\Sharing\\Property\\LabelSharePropertyType' => __DIR__ . '/../../..' . '/core/Sharing/Property/LabelSharePropertyType.php', + 'OC\\Core\\Sharing\\Property\\NoteSharePropertyType' => __DIR__ . '/../../..' . '/core/Sharing/Property/NoteSharePropertyType.php', + 'OC\\Core\\Sharing\\Property\\PasswordSharePropertyType' => __DIR__ . '/../../..' . '/core/Sharing/Property/PasswordSharePropertyType.php', + 'OC\\Core\\Sharing\\Recipient\\EmailShareRecipientType' => __DIR__ . '/../../..' . '/core/Sharing/Recipient/EmailShareRecipientType.php', + 'OC\\Core\\Sharing\\Recipient\\GroupShareRecipientType' => __DIR__ . '/../../..' . '/core/Sharing/Recipient/GroupShareRecipientType.php', + 'OC\\Core\\Sharing\\Recipient\\TeamShareRecipientType' => __DIR__ . '/../../..' . '/core/Sharing/Recipient/TeamShareRecipientType.php', + 'OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType' => __DIR__ . '/../../..' . '/core/Sharing/Recipient/TokenShareRecipientType.php', + 'OC\\Core\\Sharing\\Recipient\\UserShareRecipientType' => __DIR__ . '/../../..' . '/core/Sharing/Recipient/UserShareRecipientType.php', 'OC\\DB\\Adapter' => __DIR__ . '/../../..' . '/lib/private/DB/Adapter.php', 'OC\\DB\\AdapterMySQL' => __DIR__ . '/../../..' . '/lib/private/DB/AdapterMySQL.php', 'OC\\DB\\AdapterOCI8' => __DIR__ . '/../../..' . '/lib/private/DB/AdapterOCI8.php', diff --git a/psalm-strict.xml b/psalm-strict.xml index f546968904af6..0a0929d9b68fb 100644 --- a/psalm-strict.xml +++ b/psalm-strict.xml @@ -49,7 +49,11 @@ + + + + diff --git a/tests/Core/Sharing/Property/ExpirationDateSharePropertyTypeTest.php b/tests/Core/Sharing/Property/ExpirationDateSharePropertyTypeTest.php new file mode 100644 index 0000000000000..0de4bc1c87c1a --- /dev/null +++ b/tests/Core/Sharing/Property/ExpirationDateSharePropertyTypeTest.php @@ -0,0 +1,167 @@ +createUser('user', 'password'); + $this->assertNotFalse($user); + $this->user = $user; + + $this->propertyType = new ExpirationDateSharePropertyType(); + } + + #[\Override] + protected function tearDown(): void { + parent::tearDown(); + + $this->user->delete(); + } + + private function createDummyShare(ShareProperty $property): Share { + return new Share( + '123', + new ShareUser($this->user->getUID(), null), + 0, + ShareState::Active, + [], + [], + [$property->class => $property], + [], + ); + } + + /** @psalm-suppress DeprecatedMethod The configs are only partly migrated to IAppConfig, so using deprecated IConfig is easier for now. */ + public function testGetRequired(): void { + $config = Server::get(IConfig::class); + $config->setAppValue(Application::APP_ID, 'shareapi_default_expire_date', 'yes'); + $config->setAppValue(Application::APP_ID, 'shareapi_default_remote_expire_date', 'yes'); + $config->setAppValue(Application::APP_ID, 'shareapi_default_internal_expire_date', 'yes'); + + $keys = ['shareapi_enforce_expire_date', 'shareapi_enforce_remote_expire_date', 'shareapi_enforce_internal_expire_date']; + foreach ($keys as $key) { + $config->deleteAppValue(Application::APP_ID, $key); + } + + $this->assertFalse($this->propertyType->isRequired()); + + foreach ($keys as $key) { + $config->setAppValue(Application::APP_ID, $key, 'yes'); + $this->assertTrue($this->propertyType->isRequired(), $key); + $config->deleteAppValue(Application::APP_ID, $key); + } + + $this->assertFalse($this->propertyType->isRequired()); + + $config->deleteAppValue(Application::APP_ID, 'shareapi_default_expire_date'); + $config->deleteAppValue(Application::APP_ID, 'shareapi_default_remote_expire_date'); + $config->deleteAppValue(Application::APP_ID, 'shareapi_default_internal_expire_date'); + } + + /** @psalm-suppress DeprecatedMethod The configs are only partly migrated to IAppConfig, so using deprecated IConfig is easier for now. */ + public function testGetDefaultValue(): void { + /** @var DateTimeImmutable $now */ + $now = self::invokePrivate($this->propertyType, 'now'); + + $config = Server::get(IConfig::class); + + $keys = ['shareapi_default_expire_date', 'shareapi_default_remote_expire_date', 'shareapi_default_internal_expire_date']; + foreach ($keys as $key) { + $config->deleteAppValue(Application::APP_ID, $key); + } + + $this->assertNull($this->propertyType->getDefaultValue()); + + foreach ($keys as $key) { + $config->setAppValue(Application::APP_ID, $key, 'yes'); + $this->assertEquals($now->add(new DateInterval('P7D'))->format(DateTimeInterface::ATOM), $this->propertyType->getDefaultValue()); + $config->deleteAppValue(Application::APP_ID, $key); + } + } + + /** + * @return list + */ + public static function dataGetMinMaxDate(): array { + return [ + ['shareapi_default_expire_date', 'shareapi_enforce_expire_date', 'shareapi_expire_after_n_days'], + ['shareapi_default_remote_expire_date', 'shareapi_enforce_remote_expire_date', 'shareapi_remote_expire_after_n_days'], + ['shareapi_default_internal_expire_date', 'shareapi_enforce_internal_expire_date', 'shareapi_internal_expire_after_n_days'], + ]; + } + + /** @psalm-suppress DeprecatedMethod The configs are only partly migrated to IAppConfig, so using deprecated IConfig is easier for now. */ + #[DataProvider('dataGetMinMaxDate')] + public function testGetMinMaxDate(string $defaultEnabledKey, string $defaultEnforcedKey, string $defaultValueKey): void { + /** @var DateTimeImmutable $now */ + $now = self::invokePrivate($this->propertyType, 'now'); + + $config = Server::get(IConfig::class); + + foreach (array_merge(...self::dataGetMinMaxDate()) as $key) { + $config->deleteAppValue(Application::APP_ID, $key); + } + + $this->assertEquals($now->add(new DateInterval('PT5M')), $this->propertyType->getMinDate()); + $this->assertNull($this->propertyType->getMaxDate()); + + $config->setAppValue(Application::APP_ID, $defaultEnabledKey, 'yes'); + + $this->assertEquals($now->add(new DateInterval('PT5M')), $this->propertyType->getMinDate()); + $this->assertNull($this->propertyType->getMaxDate()); + + $config->setAppValue(Application::APP_ID, $defaultEnforcedKey, 'yes'); + $config->setAppValue(Application::APP_ID, $defaultValueKey, '123'); + + $this->assertEquals($now->add(new DateInterval('PT5M')), $this->propertyType->getMinDate()); + $this->assertEquals($now->add(new DateInterval('P123DT5M')), $this->propertyType->getMaxDate()); + + $config->deleteAppValue(Application::APP_ID, $defaultEnabledKey); + $config->deleteAppValue(Application::APP_ID, $defaultEnforcedKey); + $config->deleteAppValue(Application::APP_ID, $defaultValueKey); + } + + public function testIsFiltered(): void { + /** @var DateTimeImmutable $now */ + $now = self::invokePrivate($this->propertyType, 'now'); + $future = $now->add(new DateInterval('PT1M'))->format(DateTimeInterface::ATOM); + $past = $now->sub(new DateInterval('PT1M'))->format(DateTimeInterface::ATOM); + + $this->assertFalse($this->propertyType->isFiltered(new ShareAccessContext(), $this->createDummyShare(new ShareProperty($this->propertyType::class, $future)))); + $this->assertTrue($this->propertyType->isFiltered(new ShareAccessContext(), $this->createDummyShare(new ShareProperty($this->propertyType::class, $now->format(DateTimeInterface::ATOM))))); + $this->assertTrue($this->propertyType->isFiltered(new ShareAccessContext(), $this->createDummyShare(new ShareProperty($this->propertyType::class, $past)))); + } +} diff --git a/tests/Core/Sharing/Property/PasswordSharePropertyTypeTest.php b/tests/Core/Sharing/Property/PasswordSharePropertyTypeTest.php new file mode 100644 index 0000000000000..f87d51700a9a5 --- /dev/null +++ b/tests/Core/Sharing/Property/PasswordSharePropertyTypeTest.php @@ -0,0 +1,73 @@ +createUser('user', 'password'); + $this->assertNotFalse($user); + $this->user = $user; + + $this->propertyType = new PasswordSharePropertyType(); + } + + #[\Override] + protected function tearDown(): void { + parent::tearDown(); + + $this->user->delete(); + } + + private function createDummyShare(?ShareProperty $property): Share { + $properties = []; + if ($property instanceof ShareProperty) { + $properties[$property->class] = $property; + } + + return new Share( + '123', + new ShareUser($this->user->getUID(), null), + 0, + ShareState::Active, + [], + [], + $properties, + [], + ); + } + + public function testIsFiltered(): void { + $this->assertFalse($this->propertyType->isFiltered(new ShareAccessContext(arguments: [$this->propertyType::class => '123']), $this->createDummyShare(new ShareProperty($this->propertyType::class, Server::get(IHasher::class)->hash('123'))))); + $this->assertFalse($this->propertyType->isFiltered(new ShareAccessContext(arguments: [$this->propertyType::class => '123']), $this->createDummyShare(new ShareProperty($this->propertyType::class, null)))); + $this->assertFalse($this->propertyType->isFiltered(new ShareAccessContext(arguments: [$this->propertyType::class => '123']), $this->createDummyShare(null))); + $this->assertTrue($this->propertyType->isFiltered(new ShareAccessContext(arguments: [$this->propertyType::class => '456']), $this->createDummyShare(new ShareProperty($this->propertyType::class, Server::get(IHasher::class)->hash('123'))))); + $this->assertTrue($this->propertyType->isFiltered(new ShareAccessContext(arguments: [$this->propertyType::class => null]), $this->createDummyShare(new ShareProperty($this->propertyType::class, Server::get(IHasher::class)->hash('123'))))); + $this->assertTrue($this->propertyType->isFiltered(new ShareAccessContext(), $this->createDummyShare(new ShareProperty($this->propertyType::class, Server::get(IHasher::class)->hash('123'))))); + } +} diff --git a/tests/Core/Sharing/Recipient/EmailShareRecipientTypeTest.php b/tests/Core/Sharing/Recipient/EmailShareRecipientTypeTest.php new file mode 100644 index 0000000000000..348acb366c09c --- /dev/null +++ b/tests/Core/Sharing/Recipient/EmailShareRecipientTypeTest.php @@ -0,0 +1,102 @@ +createUser($uid, $password); + $this->assertNotFalse($user); + return $user; + } + + #[\Override] + public function setUp(): void { + parent::setUp(); + + $userManager = Server::get(IUserManager::class); + $userManager->clearBackends(); + $userManager->registerBackend(new Database()); + + $this->user1 = $this->createUser($userManager, 'user1', 'password'); + + self::loginAsUser($this->user1->getUID()); + + $this->recipientType = new EmailShareRecipientType(); + } + + #[\Override] + protected function tearDown(): void { + $this->user1->delete(); + + parent::tearDown(); + } + + public function testValidateRecipient(): void { + $this->assertTrue($this->recipientType->validateRecipient('example@example.com')); + $this->assertFalse($this->recipientType->validateRecipient('example')); + $this->assertFalse($this->recipientType->validateRecipient('example@example')); + $this->assertFalse($this->recipientType->validateRecipient('example.com')); + $this->assertFalse($this->recipientType->validateRecipient('@')); + } + + public function testGetRecipientDisplayName(): void { + $this->assertEquals('example@example.com', $this->recipientType->getRecipientDisplayName('example@example.com')); + } + + public function testSearchRecipients(): void { + $cardDavBackend = Server::get(CardDavBackend::class); + $addressBookId = $cardDavBackend->createAddressBook('principals/users/user1', 'Personal', []); + + $contactsManager = Server::get(IManager::class); + foreach (['email1@example.com', 'email2@example.com', 'email3@example.com', 'email4@example.com'] as $email) { + $contactsManager->createOrUpdate(['EMAIL' => $email], (string)$addressBookId); + } + + $accessContext = new ShareAccessContext(currentUser: $this->user1); + + /** @psalm-suppress ArgumentTypeCoercion */ + $generateRecipient = static fn (string $email): ShareRecipient => new ShareRecipient( + EmailShareRecipientType::class, + $email, + null, + ); + + $this->assertEquals(array_map($generateRecipient(...), ['email1@example.com', 'email2@example.com', 'email3@example.com']), $this->recipientType->searchRecipients($accessContext, 'email', 3, 0)); + $this->assertEquals(array_map($generateRecipient(...), ['email2@example.com', 'email3@example.com', 'email4@example.com']), $this->recipientType->searchRecipients($accessContext, 'email', 3, 1)); + $this->assertEquals(array_map($generateRecipient(...), ['email3@example.com', 'email4@example.com']), $this->recipientType->searchRecipients($accessContext, 'email', 4, 2)); + + $this->assertEquals(array_map($generateRecipient(...), ['email1@example.com']), $this->recipientType->searchRecipients($accessContext, 'email', 1, 0)); + $this->assertEquals(array_map($generateRecipient(...), ['email1@example.com', 'email2@example.com']), $this->recipientType->searchRecipients($accessContext, 'email', 2, 0)); + $this->assertEquals(array_map($generateRecipient(...), ['email2@example.com', 'email3@example.com']), $this->recipientType->searchRecipients($accessContext, 'email', 2, 1)); + $this->assertEquals(array_map($generateRecipient(...), ['email3@example.com', 'email4@example.com']), $this->recipientType->searchRecipients($accessContext, 'email', 2, 2)); + $this->assertEquals(array_map($generateRecipient(...), ['email4@example.com']), $this->recipientType->searchRecipients($accessContext, 'email', 2, 3)); + + $this->assertEquals(array_map($generateRecipient(...), ['email1@example.com']), $this->recipientType->searchRecipients($accessContext, 'email1', 2, 0)); + + $cardDavBackend->deleteAddressBook($addressBookId); + } +} diff --git a/tests/Core/Sharing/Recipient/GroupShareRecipientTypeTest.php b/tests/Core/Sharing/Recipient/GroupShareRecipientTypeTest.php new file mode 100644 index 0000000000000..d9caa4658c6ae --- /dev/null +++ b/tests/Core/Sharing/Recipient/GroupShareRecipientTypeTest.php @@ -0,0 +1,159 @@ + 'Group 1', + 'group2' => 'Group 2', + 'group3' => 'Group 3', + ]; + + private function createUser(IUserManager $userManager, string $uid, string $password): IUser { + $user = $userManager->createUser($uid, $password); + $this->assertNotFalse($user); + return $user; + } + + private function createGroup(IGroupManager $groupManager, string $gid): IGroup { + $group = $groupManager->createGroup($gid); + $this->assertNotNull($group); + $this->assertTrue($group->setDisplayName(self::DISPLAY_NAMES[$gid])); + return $group; + } + + #[\Override] + public function setUp(): void { + parent::setUp(); + + $this->dbConnection = Server::get(IDBConnection::class); + + $this->manager = Server::get(ISharingManager::class); + + $userManager = Server::get(IUserManager::class); + $this->user1 = $this->createUser($userManager, 'user1', 'password'); + + $groupManager = Server::get(IGroupManager::class); + $groupManager->clearBackends(); + $groupManager->addBackend(new Database()); + + $this->group1 = $this->createGroup($groupManager, 'group1'); + $this->group2 = $this->createGroup($groupManager, 'group2'); + $this->group3 = $this->createGroup($groupManager, 'group3'); + + $this->group1->addUser($this->user1); + + $this->recipientType = new GroupShareRecipientType(Server::get(IEventDispatcher::class), $this->dbConnection, $this->manager); + } + + #[\Override] + protected function tearDown(): void { + $this->user1->delete(); + $this->group1->delete(); + $this->group2->delete(); + $this->group3->delete(); + + parent::tearDown(); + } + + public function testValidateRecipient(): void { + $this->assertTrue($this->recipientType->validateRecipient('group1')); + $this->assertFalse($this->recipientType->validateRecipient('invalid')); + } + + public function testGetRecipientValues(): void { + $this->assertEquals(['group1'], $this->recipientType->getRecipients($this->user1, null)); + } + + public function testGetRecipientDisplayName(): void { + // Clear display name cache, because setting the display name on the group doesn't update it in the cache of the manager + self::invokePrivate(self::invokePrivate(Server::get(IGroupManager::class), 'displayNameCache'), 'clear'); + + $this->assertEquals('Group 1', $this->recipientType->getRecipientDisplayName($this->group1->getGID())); + } + + public function testSearchRecipients(): void { + $accessContext = new ShareAccessContext(currentUser: $this->user1); + self::loginAsUser($this->user1->getUID()); + + $generateRecipient = static fn (IGroup $group): ShareRecipient => new ShareRecipient( + GroupShareRecipientType::class, + $group->getGID(), + null, + ); + + $this->assertEquals(array_map($generateRecipient(...), [$this->group1, $this->group2, $this->group3]), $this->recipientType->searchRecipients($accessContext, 'group', 3, 0)); + $this->assertEquals(array_map($generateRecipient(...), [$this->group1]), $this->recipientType->searchRecipients($accessContext, 'group', 1, 0)); + $this->assertEquals(array_map($generateRecipient(...), [$this->group2, $this->group3]), $this->recipientType->searchRecipients($accessContext, 'group', 3, 1)); + $this->assertEquals(array_map($generateRecipient(...), [$this->group2]), $this->recipientType->searchRecipients($accessContext, 'group', 1, 1)); + + $this->assertEquals(array_map($generateRecipient(...), [$this->group1]), $this->recipientType->searchRecipients($accessContext, 'group1', 1, 0)); + } + + public function testDelete(): void { + $registry = Server::get(ISharingRegistry::class); + $registry->clear(); + $registry->registerSharingBackend(Server::get(SharingBackend::class)); + $registry->registerRecipientType($this->recipientType); + + $accessContext = new ShareAccessContext(currentUser: $this->user1); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient($this->recipientType::class, $this->group1->getGID(), null)); + $this->dbConnection->commit(); + + $before = $this->manager->generateTimestamp(); + $this->group1->delete(); + $after = $this->manager->generateTimestamp(); + + $this->dbConnection->beginTransaction(); + $share = $this->manager->getShare($accessContext, $id); + $this->assertGreaterThanOrEqual($before, $share->lastUpdated); + $this->assertLessThanOrEqual($after, $share->lastUpdated); + $this->assertEquals([], $share->recipients); + + $this->manager->deleteShare($accessContext, $id); + $this->dbConnection->commit(); + $registry->clear(); + } +} diff --git a/tests/Core/Sharing/Recipient/TeamShareRecipientTypeTest.php b/tests/Core/Sharing/Recipient/TeamShareRecipientTypeTest.php new file mode 100644 index 0000000000000..efa8036a6539a --- /dev/null +++ b/tests/Core/Sharing/Recipient/TeamShareRecipientTypeTest.php @@ -0,0 +1,179 @@ + 'Team 1', + 'team2' => 'Team 2', + 'team3' => 'Team 3', + ]; + + private function createUser(IUserManager $userManager, string $uid, string $password): IUser { + $user = $userManager->createUser($uid, $password); + $this->assertNotFalse($user); + return $user; + } + + private function createTeam(ITeamManager $teamManager, string $name): Team { + $circlesManager = Server::get(CirclesManager::class); + $circlesManager->startSession($circlesManager->getLocalFederatedUser($this->user1->getUID())); + + $circle = $circlesManager->createCircle($name); + Server::get(CircleService::class)->updateName($circle->getSingleId(), self::DISPLAY_NAMES[$name]); + + $team = $teamManager->getTeam($circle->getSingleId(), $this->user1->getUID()); + $this->assertNotNull($team); + return $team; + } + + #[\Override] + public function setUp(): void { + parent::setUp(); + + $this->dbConnection = Server::get(IDBConnection::class); + + $this->manager = Server::get(ISharingManager::class); + + $userManager = Server::get(IUserManager::class); + $this->user1 = $this->createUser($userManager, 'user1', 'password'); + + $teamManager = Server::get(ITeamManager::class); + + $this->team1 = $this->createTeam($teamManager, 'team1'); + $this->team2 = $this->createTeam($teamManager, 'team2'); + $this->team3 = $this->createTeam($teamManager, 'team3'); + + $this->recipientType = new TeamShareRecipientType(Server::get(IEventDispatcher::class), $this->dbConnection, $this->manager); + } + + #[\Override] + protected function tearDown(): void { + $circlesManager = Server::get(CirclesManager::class); + $circlesManager->startSession($circlesManager->getLocalFederatedUser($this->user1->getUID())); + try { + $circlesManager->destroyCircle($this->team1->getId()); + } catch (CircleNotFoundException) { + } + + $circlesManager->destroyCircle($this->team2->getId()); + $circlesManager->destroyCircle($this->team3->getId()); + + $this->user1->delete(); + + parent::tearDown(); + } + + /** @psalm-suppress UnevaluatedCode Test is skipped */ + public function testValidateRecipient(): void { + $this->markTestSkipped('This test is broken if run together with other test suites because circles 🤷‍♀️'); + $this->assertTrue($this->recipientType->validateRecipient($this->team1->getId())); + $this->assertFalse($this->recipientType->validateRecipient('invalid')); + } + + public function testGetRecipientValues(): void { + $this->assertContains($this->team1->getId(), $this->recipientType->getRecipients($this->user1, null)); + } + + /** @psalm-suppress UnevaluatedCode Test is skipped */ + public function testGetRecipientDisplayName(): void { + $this->markTestSkipped('This test is broken if run together with other test suites because circles 🤷‍♀️'); + $this->assertEquals('Team 1', $this->recipientType->getRecipientDisplayName($this->team1->getId())); + } + + public function testSearchRecipients(): void { + $accessContext = new ShareAccessContext(currentUser: $this->user1); + self::loginAsUser($this->user1->getUID()); + + $generateRecipient = static fn (Team $team): ShareRecipient => new ShareRecipient( + TeamShareRecipientType::class, + $team->getId(), + null, + ); + + $this->assertEquals(array_map($generateRecipient(...), [$this->team1, $this->team2, $this->team3]), $this->recipientType->searchRecipients($accessContext, 'Team', 3, 0)); + $this->assertEquals(array_map($generateRecipient(...), [$this->team1]), $this->recipientType->searchRecipients($accessContext, 'Team', 1, 0)); + $this->assertEquals(array_map($generateRecipient(...), [$this->team2, $this->team3]), $this->recipientType->searchRecipients($accessContext, 'Team', 3, 1)); + $this->assertEquals(array_map($generateRecipient(...), [$this->team2]), $this->recipientType->searchRecipients($accessContext, 'Team', 1, 1)); + + $this->assertEquals(array_map($generateRecipient(...), [$this->team1]), $this->recipientType->searchRecipients($accessContext, 'Team 1', 1, 0)); + } + + /** @psalm-suppress UnevaluatedCode Test is skipped */ + public function testDelete(): void { + $this->markTestSkipped('This test is broken if run together with other test suites because circles 🤷‍♀️'); + + $registry = Server::get(ISharingRegistry::class); + $registry->clear(); + $registry->registerRecipientType($this->recipientType); + + $accessContext = new ShareAccessContext(currentUser: $this->user1); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient($this->recipientType::class, $this->team1->getId(), null)); + $this->dbConnection->commit(); + + $circlesManager = Server::get(CirclesManager::class); + $circlesManager->startSession($circlesManager->getLocalFederatedUser($this->user1->getUID())); + + $before = $this->manager->generateTimestamp(); + $circlesManager->destroyCircle($this->team1->getId()); + $after = $this->manager->generateTimestamp(); + + $this->dbConnection->beginTransaction(); + $share = $this->manager->getShare($accessContext, $id); + $this->assertGreaterThanOrEqual($before, $share->lastUpdated); + $this->assertLessThanOrEqual($after, $share->lastUpdated); + $this->assertEquals([], $share->recipients); + + $this->manager->deleteShare($accessContext, $id); + $this->dbConnection->commit(); + $registry->clear(); + } +} diff --git a/tests/Core/Sharing/Recipient/TokenShareRecipientTypeTest.php b/tests/Core/Sharing/Recipient/TokenShareRecipientTypeTest.php new file mode 100644 index 0000000000000..388c3c6b5fa72 --- /dev/null +++ b/tests/Core/Sharing/Recipient/TokenShareRecipientTypeTest.php @@ -0,0 +1,33 @@ +recipientType = new TokenShareRecipientType(); + } + + public function testValidateRecipient(): void { + $this->assertTrue($this->recipientType->validateRecipient(str_repeat('a', 32))); + $this->assertFalse($this->recipientType->validateRecipient(str_repeat('a', 32 - 1))); + $this->assertTrue($this->recipientType->validateRecipient(str_repeat('a', 255))); + $this->assertFalse($this->recipientType->validateRecipient(str_repeat('a', 255 + 1))); + } +} diff --git a/tests/Core/Sharing/Recipient/UserShareRecipientTypeTest.php b/tests/Core/Sharing/Recipient/UserShareRecipientTypeTest.php new file mode 100644 index 0000000000000..b2c9592662246 --- /dev/null +++ b/tests/Core/Sharing/Recipient/UserShareRecipientTypeTest.php @@ -0,0 +1,159 @@ + 'User 1', + 'user2' => 'User 2', + 'user3' => 'User 3', + 'user4' => 'User 4', + ]; + + private function createUser(IUserManager $userManager, string $uid, string $password): IUser { + $user = $userManager->createUser($uid, $password); + $this->assertNotFalse($user); + $this->assertTrue($user->setDisplayName(self::DISPLAY_NAMES[$uid])); + $user->setSystemEMailAddress($uid . '@example.com'); + return $user; + } + + #[\Override] + public function setUp(): void { + parent::setUp(); + + $this->dbConnection = Server::get(IDBConnection::class); + + $this->manager = Server::get(ISharingManager::class); + + $userManager = Server::get(IUserManager::class); + $userManager->clearBackends(); + $userManager->registerBackend(new Database()); + + $this->user1 = $this->createUser($userManager, 'user1', 'password'); + $this->user2 = $this->createUser($userManager, 'user2', 'password'); + $this->user3 = $this->createUser($userManager, 'user3', 'password'); + $this->user4 = $this->createUser($userManager, 'user4', 'password'); + + $this->recipientType = new UserShareRecipientType(Server::get(IEventDispatcher::class), $this->dbConnection, $this->manager); + } + + #[\Override] + protected function tearDown(): void { + $this->user1->delete(); + $this->user2->delete(); + $this->user3->delete(); + $this->user4->delete(); + + parent::tearDown(); + } + + public function testValidateRecipient(): void { + $this->assertTrue($this->recipientType->validateRecipient('user1')); + $this->assertFalse($this->recipientType->validateRecipient('invalid')); + } + + public function testGetRecipientValues(): void { + $this->assertEquals(['user1'], $this->recipientType->getRecipients($this->user1, null)); + } + + public function testGetRecipientDisplayName(): void { + $this->assertEquals('User 1', $this->recipientType->getRecipientDisplayName($this->user1->getUID())); + } + + public function testSearchRecipients(): void { + $accessContext = new ShareAccessContext(currentUser: $this->user1); + self::loginAsUser($this->user1->getUID()); + + $generateRecipient = static fn (IUser $user): ShareRecipient => new ShareRecipient( + UserShareRecipientType::class, + $user->getUID(), + null, + ); + + // The UserPlugin already removes the current user (user1 here), leading to one result less than requested. + // This is an issue of the Collaborators API and can't be easily fixed. + // If the following tests fail, because different numbers of results are returned: congratulations, you fixed the problem! + + $this->assertEquals(array_map($generateRecipient(...), [$this->user2, $this->user3, $this->user4]), $this->recipientType->searchRecipients($accessContext, 'user', 3, 0)); + $this->assertEquals(array_map($generateRecipient(...), [$this->user2, $this->user3, $this->user4]), $this->recipientType->searchRecipients($accessContext, 'user', 4, 0)); + // Wrong: Offset not applied correctly + $this->assertEquals(array_map($generateRecipient(...), [$this->user2, $this->user3, $this->user4]), $this->recipientType->searchRecipients($accessContext, 'user', 4, 1)); + $this->assertEquals(array_map($generateRecipient(...), [$this->user3, $this->user4]), $this->recipientType->searchRecipients($accessContext, 'user', 4, 2)); + + $this->assertEquals(array_map($generateRecipient(...), [$this->user2]), $this->recipientType->searchRecipients($accessContext, 'user', 1, 0)); + $this->assertEquals(array_map($generateRecipient(...), [$this->user2, $this->user3]), $this->recipientType->searchRecipients($accessContext, 'user', 2, 0)); + // Wrong: Offset not applied correctly + $this->assertEquals(array_map($generateRecipient(...), [$this->user2, $this->user3, $this->user4]), $this->recipientType->searchRecipients($accessContext, 'user', 2, 1)); + $this->assertEquals(array_map($generateRecipient(...), [$this->user3, $this->user4]), $this->recipientType->searchRecipients($accessContext, 'user', 2, 2)); + $this->assertEquals(array_map($generateRecipient(...), [$this->user4]), $this->recipientType->searchRecipients($accessContext, 'user', 2, 3)); + + $this->assertEquals(array_map($generateRecipient(...), [$this->user2]), $this->recipientType->searchRecipients($accessContext, 'user2', 2, 0)); + $this->assertEquals(array_map($generateRecipient(...), [$this->user2]), $this->recipientType->searchRecipients($accessContext, 'user2@example.com', 2, 0)); + } + + public function testDelete(): void { + $registry = Server::get(ISharingRegistry::class); + $registry->clear(); + $registry->registerSharingBackend(Server::get(SharingBackend::class)); + $registry->registerRecipientType($this->recipientType); + + $accessContext = new ShareAccessContext(currentUser: $this->user1); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareRecipient($accessContext, $id, new ShareRecipient($this->recipientType::class, $this->user2->getUID(), null)); + $this->dbConnection->commit(); + + $before = $this->manager->generateTimestamp(); + $this->user2->delete(); + $after = $this->manager->generateTimestamp(); + + $this->dbConnection->beginTransaction(); + $share = $this->manager->getShare($accessContext, $id); + $this->assertGreaterThanOrEqual($before, $share->lastUpdated); + $this->assertLessThanOrEqual($after, $share->lastUpdated); + $this->assertEquals([], $share->recipients); + + $this->manager->deleteShare($accessContext, $id); + $this->dbConnection->commit(); + $registry->clear(); + } +} From 10a6b576cedc430ea207d8485f01eb6c0564719b Mon Sep 17 00:00:00 2001 From: provokateurin Date: Mon, 4 May 2026 13:44:49 +0200 Subject: [PATCH 4/5] feat(files/Sharing): Init Signed-off-by: provokateurin --- .../composer/composer/autoload_classmap.php | 7 + .../composer/composer/autoload_static.php | 7 + apps/files/lib/AppInfo/Application.php | 45 +++++++ .../NodeCreateSharePermissionType.php | 45 +++++++ .../NodeDeleteSharePermissionType.php | 45 +++++++ .../NodeDownloadSharePermissionType.php | 49 +++++++ .../NodeReadSharePermissionType.php | 36 ++++++ .../NodeUpdateSharePermissionType.php | 45 +++++++ .../NodeGridViewSharePropertyType.php | 46 +++++++ .../Sharing/Source/NodeShareSourceType.php | 101 +++++++++++++++ .../Source/NodeShareSourceTypeTest.php | 120 ++++++++++++++++++ build/rector-strict.php | 2 + psalm-strict.xml | 3 + 13 files changed, 551 insertions(+) create mode 100644 apps/files/lib/Sharing/Permission/NodeCreateSharePermissionType.php create mode 100644 apps/files/lib/Sharing/Permission/NodeDeleteSharePermissionType.php create mode 100644 apps/files/lib/Sharing/Permission/NodeDownloadSharePermissionType.php create mode 100644 apps/files/lib/Sharing/Permission/NodeReadSharePermissionType.php create mode 100644 apps/files/lib/Sharing/Permission/NodeUpdateSharePermissionType.php create mode 100644 apps/files/lib/Sharing/Property/NodeGridViewSharePropertyType.php create mode 100644 apps/files/lib/Sharing/Source/NodeShareSourceType.php create mode 100644 apps/files/tests/Sharing/Source/NodeShareSourceTypeTest.php diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php index 2e2cb6da8a601..80e87ce2a96fc 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/apps/files/composer/composer/autoload_classmap.php @@ -98,4 +98,11 @@ 'OCA\\Files\\Service\\ViewConfig' => $baseDir . '/../lib/Service/ViewConfig.php', 'OCA\\Files\\Settings\\AdminSettings' => $baseDir . '/../lib/Settings/AdminSettings.php', 'OCA\\Files\\Settings\\PersonalSettings' => $baseDir . '/../lib/Settings/PersonalSettings.php', + 'OCA\\Files\\Sharing\\Permission\\NodeCreateSharePermissionType' => $baseDir . '/../lib/Sharing/Permission/NodeCreateSharePermissionType.php', + 'OCA\\Files\\Sharing\\Permission\\NodeDeleteSharePermissionType' => $baseDir . '/../lib/Sharing/Permission/NodeDeleteSharePermissionType.php', + 'OCA\\Files\\Sharing\\Permission\\NodeDownloadSharePermissionType' => $baseDir . '/../lib/Sharing/Permission/NodeDownloadSharePermissionType.php', + '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\\Source\\NodeShareSourceType' => $baseDir . '/../lib/Sharing/Source/NodeShareSourceType.php', ); diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php index d7786af11072c..4dfc62aedb094 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/apps/files/composer/composer/autoload_static.php @@ -113,6 +113,13 @@ class ComposerStaticInitFiles 'OCA\\Files\\Service\\ViewConfig' => __DIR__ . '/..' . '/../lib/Service/ViewConfig.php', 'OCA\\Files\\Settings\\AdminSettings' => __DIR__ . '/..' . '/../lib/Settings/AdminSettings.php', 'OCA\\Files\\Settings\\PersonalSettings' => __DIR__ . '/..' . '/../lib/Settings/PersonalSettings.php', + 'OCA\\Files\\Sharing\\Permission\\NodeCreateSharePermissionType' => __DIR__ . '/..' . '/../lib/Sharing/Permission/NodeCreateSharePermissionType.php', + 'OCA\\Files\\Sharing\\Permission\\NodeDeleteSharePermissionType' => __DIR__ . '/..' . '/../lib/Sharing/Permission/NodeDeleteSharePermissionType.php', + 'OCA\\Files\\Sharing\\Permission\\NodeDownloadSharePermissionType' => __DIR__ . '/..' . '/../lib/Sharing/Permission/NodeDownloadSharePermissionType.php', + '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\\Source\\NodeShareSourceType' => __DIR__ . '/..' . '/../lib/Sharing/Source/NodeShareSourceType.php', ); public static function getInitializer(ClassLoader $loader) diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php index 15cbd7f33862e..ad8f886665368 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -9,6 +9,13 @@ namespace OCA\Files\AppInfo; +use OC\Core\Sharing\Permission\EditSharePermissionPreset; +use OC\Core\Sharing\Permission\ViewSharePermissionPreset; +use OC\Core\Sharing\Property\ExpirationDateSharePropertyType; +use OC\Core\Sharing\Property\LabelSharePropertyType; +use OC\Core\Sharing\Property\NoteSharePropertyType; +use OC\Core\Sharing\Property\PasswordSharePropertyType; +use OC\Core\Sharing\Recipient\TokenShareRecipientType; use OCA\Files\AdvancedCapabilities; use OCA\Files\Capabilities; use OCA\Files\Collaboration\Resources\Listener; @@ -28,6 +35,13 @@ use OCA\Files\Listener\UserFirstTimeLoggedInListener; use OCA\Files\Notification\Notifier; use OCA\Files\Search\FilesSearchProvider; +use OCA\Files\Sharing\Permission\NodeCreateSharePermissionType; +use OCA\Files\Sharing\Permission\NodeDeleteSharePermissionType; +use OCA\Files\Sharing\Permission\NodeDownloadSharePermissionType; +use OCA\Files\Sharing\Permission\NodeReadSharePermissionType; +use OCA\Files\Sharing\Permission\NodeUpdateSharePermissionType; +use OCA\Files\Sharing\Property\NodeGridViewSharePropertyType; +use OCA\Files\Sharing\Source\NodeShareSourceType; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -42,9 +56,11 @@ use OCP\Files\Events\NodeAddedToFavorite; use OCP\Files\Events\NodeRemovedFromFavorite; use OCP\Interaction\RestrictInteractionEvent; +use OCP\Server; use OCP\Share\Events\ShareCreatedEvent; use OCP\Share\Events\ShareDeletedEvent; use OCP\Share\Events\ShareDeletedFromSelfEvent; +use OCP\Sharing\ISharingRegistry; use OCP\User\Events\UserFirstTimeLoggedInEvent; class Application extends App implements IBootstrap { @@ -87,6 +103,35 @@ public function register(IRegistrationContext $context): void { $context->registerConfigLexicon(ConfigLexicon::class); $context->registerEventListener(RestrictInteractionEvent::class, RestrictInteractionListener::class); + + $registry = Server::get(ISharingRegistry::class); + + $registry->registerSourceType(Server::get(NodeShareSourceType::class)); + $registry->markPropertyTypeCompatibleWithSourceType(ExpirationDateSharePropertyType::class, NodeShareSourceType::class); + $registry->markPropertyTypeCompatibleWithSourceType(LabelSharePropertyType::class, NodeShareSourceType::class); + $registry->markPropertyTypeCompatibleWithSourceType(NoteSharePropertyType::class, NodeShareSourceType::class); + $registry->markPropertyTypeCompatibleWithSourceType(PasswordSharePropertyType::class, NodeShareSourceType::class); + + $registry->registerPropertyType(new NodeGridViewSharePropertyType()); + $registry->markPropertyTypeCompatibleWithSourceType(NodeGridViewSharePropertyType::class, NodeShareSourceType::class); + $registry->markPropertyTypeCompatibleWithRecipientType(NodeGridViewSharePropertyType::class, TokenShareRecipientType::class); + + $registry->registerPermissionType(NodeShareSourceType::class, new NodeCreateSharePermissionType()); + $registry->markPermissionTypeCompatibleWithPermissionPreset(NodeCreateSharePermissionType::class, EditSharePermissionPreset::class); + + $registry->registerPermissionType(NodeShareSourceType::class, new NodeReadSharePermissionType()); + $registry->markPermissionTypeCompatibleWithPermissionPreset(NodeReadSharePermissionType::class, ViewSharePermissionPreset::class); + $registry->markPermissionTypeCompatibleWithPermissionPreset(NodeReadSharePermissionType::class, EditSharePermissionPreset::class); + + $registry->registerPermissionType(NodeShareSourceType::class, new NodeUpdateSharePermissionType()); + $registry->markPermissionTypeCompatibleWithPermissionPreset(NodeUpdateSharePermissionType::class, EditSharePermissionPreset::class); + + $registry->registerPermissionType(NodeShareSourceType::class, new NodeDeleteSharePermissionType()); + $registry->markPermissionTypeCompatibleWithPermissionPreset(NodeDeleteSharePermissionType::class, EditSharePermissionPreset::class); + + $registry->registerPermissionType(NodeShareSourceType::class, new NodeDownloadSharePermissionType()); + $registry->markPermissionTypeCompatibleWithPermissionPreset(NodeDownloadSharePermissionType::class, ViewSharePermissionPreset::class); + $registry->markPermissionTypeCompatibleWithPermissionPreset(NodeDownloadSharePermissionType::class, EditSharePermissionPreset::class); } #[\Override] diff --git a/apps/files/lib/Sharing/Permission/NodeCreateSharePermissionType.php b/apps/files/lib/Sharing/Permission/NodeCreateSharePermissionType.php new file mode 100644 index 0000000000000..476d491441346 --- /dev/null +++ b/apps/files/lib/Sharing/Permission/NodeCreateSharePermissionType.php @@ -0,0 +1,45 @@ +appConfig ??= Server::get(IAppConfig::class); + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + return $l10nFactory->get(Application::APP_ID)->t('Create files'); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + return null; + } + + #[\Override] + public function getPriority(): int { + return 60; + } + + #[\Override] + public function isEnabledByDefault(): bool { + return ($this->getAppConfig()->getValueInt(\OC\Core\AppInfo\Application::APP_ID, 'shareapi_default_permissions') & Constants::PERMISSION_CREATE) === Constants::PERMISSION_CREATE; + } +} diff --git a/apps/files/lib/Sharing/Permission/NodeDeleteSharePermissionType.php b/apps/files/lib/Sharing/Permission/NodeDeleteSharePermissionType.php new file mode 100644 index 0000000000000..bcaf4825435f8 --- /dev/null +++ b/apps/files/lib/Sharing/Permission/NodeDeleteSharePermissionType.php @@ -0,0 +1,45 @@ +appConfig ??= Server::get(IAppConfig::class); + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + return $l10nFactory->get(Application::APP_ID)->t('Delete files'); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + return null; + } + + #[\Override] + public function getPriority(): int { + return 50; + } + + #[\Override] + public function isEnabledByDefault(): bool { + return ($this->getAppConfig()->getValueInt(\OC\Core\AppInfo\Application::APP_ID, 'shareapi_default_permissions') & Constants::PERMISSION_DELETE) === Constants::PERMISSION_DELETE; + } +} diff --git a/apps/files/lib/Sharing/Permission/NodeDownloadSharePermissionType.php b/apps/files/lib/Sharing/Permission/NodeDownloadSharePermissionType.php new file mode 100644 index 0000000000000..f513199517763 --- /dev/null +++ b/apps/files/lib/Sharing/Permission/NodeDownloadSharePermissionType.php @@ -0,0 +1,49 @@ +legacyManager ??= Server::get(IManager::class); + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + return $l10nFactory->get(Application::APP_ID)->t('Download files'); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + // If previews are still allowed, the download option is only hidden, because on a technical level it is still possible to download. + if ($this->getLegacyManager()->allowViewWithoutDownload()) { + return $l10nFactory->get(Application::APP_ID)->t('When disabled, the option to download will be hidden'); + } + + return null; + } + + #[\Override] + public function getPriority(): int { + return 40; + } + + #[\Override] + public function isEnabledByDefault(): bool { + return false; + } +} diff --git a/apps/files/lib/Sharing/Permission/NodeReadSharePermissionType.php b/apps/files/lib/Sharing/Permission/NodeReadSharePermissionType.php new file mode 100644 index 0000000000000..cdaf7fd558e1b --- /dev/null +++ b/apps/files/lib/Sharing/Permission/NodeReadSharePermissionType.php @@ -0,0 +1,36 @@ +get(Application::APP_ID)->t('View files'); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + return null; + } + + #[\Override] + public function getPriority(): int { + return 80; + } + + #[\Override] + public function isEnabledByDefault(): bool { + return true; + } +} diff --git a/apps/files/lib/Sharing/Permission/NodeUpdateSharePermissionType.php b/apps/files/lib/Sharing/Permission/NodeUpdateSharePermissionType.php new file mode 100644 index 0000000000000..87d046eb2af40 --- /dev/null +++ b/apps/files/lib/Sharing/Permission/NodeUpdateSharePermissionType.php @@ -0,0 +1,45 @@ +appConfig ??= Server::get(IAppConfig::class); + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + return $l10nFactory->get(Application::APP_ID)->t('Edit files'); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + return null; + } + + #[\Override] + public function getPriority(): int { + return 70; + } + + #[\Override] + public function isEnabledByDefault(): bool { + return ($this->getAppConfig()->getValueInt(\OC\Core\AppInfo\Application::APP_ID, 'shareapi_default_permissions') & Constants::PERMISSION_UPDATE) === Constants::PERMISSION_UPDATE; + } +} diff --git a/apps/files/lib/Sharing/Property/NodeGridViewSharePropertyType.php b/apps/files/lib/Sharing/Property/NodeGridViewSharePropertyType.php new file mode 100644 index 0000000000000..9aee9df7c210f --- /dev/null +++ b/apps/files/lib/Sharing/Property/NodeGridViewSharePropertyType.php @@ -0,0 +1,46 @@ +get(Application::APP_ID)->t('Show files in grid view'); + } + + #[\Override] + public function getHint(IFactory $l10nFactory): ?string { + return null; + } + + #[\Override] + public function getPriority(): int { + return 30; + } + + #[\Override] + public function isAdvanced(): bool { + return true; + } + + #[\Override] + public function isRequired(): bool { + return false; + } + + #[\Override] + public function getDefaultValue(): string { + return 'false'; + } +} diff --git a/apps/files/lib/Sharing/Source/NodeShareSourceType.php b/apps/files/lib/Sharing/Source/NodeShareSourceType.php new file mode 100644 index 0000000000000..df99e5aa48743 --- /dev/null +++ b/apps/files/lib/Sharing/Source/NodeShareSourceType.php @@ -0,0 +1,101 @@ + + */ +final class NodeShareSourceType implements IShareSourceType, IEventListener { + private ?IRootFolder $rootFolder = null; + + private ?IURLGenerator $urlGenerator = null; + + public function __construct( + IEventDispatcher $eventDispatcher, + private readonly IDBConnection $dbConnection, + private readonly ISharingManager $manager, + ) { + $eventDispatcher->addServiceListener(NodeDeletedEvent::class, self::class); + $eventDispatcher->addServiceListener(MoveToTrashEvent::class, self::class); + } + + private function getRootFolder(): IRootFolder { + return $this->rootFolder ??= Server::get(IRootFolder::class); + } + + private function getUrlGenerator(): IURLGenerator { + return $this->urlGenerator ??= Server::get(IURLGenerator::class); + } + + #[\Override] + public function getDisplayName(IFactory $l10nFactory): string { + return $l10nFactory->get(Application::APP_ID)->t('File'); + } + + #[\Override] + public function validateSource(string $source): bool { + return $this->getRootFolder()->getFirstNodeById((int)$source) instanceof Node; + } + + #[\Override] + public function getSourceDisplayName(string $source): ?string { + $displayName = $this->getRootFolder()->getFirstNodeById((int)$source)?->getName(); + if ($displayName === '') { + return null; + } + + return $displayName; + } + + #[\Override] + public function getSourceIcon(string $source): ShareIconURL { + $url = $this->getUrlGenerator()->linkToRouteAbsolute('core.Preview.getPreviewByFileId', ['fileId' => $source, 'x' => 64, 'y' => 64]); + + return new ShareIconURL($url, $url); + } + + #[\Override] + public function getSourceInteractionResource(string $userId, string $source): InteractionResource { + return new NodeResource((int)$source, $userId); + } + + #[\Override] + public function handle(Event $event): void { + try { + $this->dbConnection->beginTransaction(); + $this->manager->onSourceDeleted(new ShareAccessContext(overrideChecks: true), new ShareSource(self::class, (string)$event->getNode()->getId())); + $this->dbConnection->commit(); + } catch (Exception $exception) { + $this->dbConnection->rollBack(); + throw $exception; + } + } +} diff --git a/apps/files/tests/Sharing/Source/NodeShareSourceTypeTest.php b/apps/files/tests/Sharing/Source/NodeShareSourceTypeTest.php new file mode 100644 index 0000000000000..003f909bf6cae --- /dev/null +++ b/apps/files/tests/Sharing/Source/NodeShareSourceTypeTest.php @@ -0,0 +1,120 @@ +dbConnection = Server::get(IDBConnection::class); + + $this->manager = Server::get(ISharingManager::class); + + $userManager = Server::get(IUserManager::class); + $userManager->clearBackends(); + $userManager->registerBackend(new Database()); + + $user1 = $userManager->createUser('user1', 'password'); + $this->assertNotFalse($user1); + $this->user1 = $user1; + + $userFolder = Server::get(IRootFolder::class)->getUserFolder($this->user1->getUID()); + $this->node = $userFolder->newFile('foo.txt', 'bar'); + + $this->sourceType = new NodeShareSourceType(Server::get(IEventDispatcher::class), $this->dbConnection, $this->manager); + } + + #[\Override] + protected function tearDown(): void { + $this->user1->delete(); + + Filesystem::tearDown(); + + parent::tearDown(); + } + + public function testValidateSource(): void { + $this->assertTrue($this->sourceType->validateSource((string)$this->node->getId())); + $this->assertFalse($this->sourceType->validateSource('-1')); + } + + public function testGetSourceDisplayName(): void { + $this->assertEquals('foo.txt', $this->sourceType->getSourceDisplayName((string)$this->node->getId())); + } + + public function testGetSourceIcon(): void { + $source = (string)$this->node->getId(); + + $this->assertEquals( + new ShareIconURL( + 'http://localhost/index.php/core/preview?fileId=' . $source . '&x=64&y=64', + 'http://localhost/index.php/core/preview?fileId=' . $source . '&x=64&y=64', + ), + $this->sourceType->getSourceIcon($source), + ); + } + + public function testDelete(): void { + $registry = Server::get(ISharingRegistry::class); + $registry->clear(); + $registry->registerSharingBackend(Server::get(SharingBackend::class)); + $registry->registerSourceType($this->sourceType); + + $accessContext = new ShareAccessContext(currentUser: $this->user1); + + $this->dbConnection->beginTransaction(); + $id = $this->manager->createShare($accessContext); + $this->manager->addShareSource($accessContext, $id, new ShareSource($this->sourceType::class, (string)$this->node->getId())); + $this->dbConnection->commit(); + + $before = $this->manager->generateTimestamp(); + $this->node->delete(); + $after = $this->manager->generateTimestamp(); + + $this->dbConnection->beginTransaction(); + $share = $this->manager->getShare($accessContext, $id); + $this->assertGreaterThanOrEqual($before, $share->lastUpdated); + $this->assertLessThanOrEqual($after, $share->lastUpdated); + $this->assertEquals([], $share->sources); + + $this->manager->deleteShare($accessContext, $id); + $this->dbConnection->commit(); + $registry->clear(); + } +} diff --git a/build/rector-strict.php b/build/rector-strict.php index 89bf909e5ed5e..18711ce0cce0e 100644 --- a/build/rector-strict.php +++ b/build/rector-strict.php @@ -44,6 +44,8 @@ $nextcloudDir . '/apps/sharing', $nextcloudDir . '/core/Sharing', $nextcloudDir . '/tests/Core/Sharing', + $nextcloudDir . '/apps/files/lib/Sharing', + $nextcloudDir . '/apps/files/tests/Sharing', ]) ->withAutoloadPaths([ // ensure rector properly autoload the public interfaces diff --git a/psalm-strict.xml b/psalm-strict.xml index 0a0929d9b68fb..6c8b59b92332b 100644 --- a/psalm-strict.xml +++ b/psalm-strict.xml @@ -51,6 +51,8 @@ + + @@ -70,6 +72,7 @@ + From 5b3870e651e49ae838fd617547f4150a61bcdd6c Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 1 Jul 2026 09:07:15 +0200 Subject: [PATCH 5/5] feat(Interaction): Add support for Unified Sharing permissions Signed-off-by: provokateurin --- .../Listener/RestrictInteractionListener.php | 56 +++++++++++-------- .../RestrictInteractionListenerTest.php | 42 +++++++++++--- .../Interaction/Actions/ShareAction.php | 3 + 3 files changed, 70 insertions(+), 31 deletions(-) diff --git a/apps/files_sharing/lib/Listener/RestrictInteractionListener.php b/apps/files_sharing/lib/Listener/RestrictInteractionListener.php index 94c1d399a018a..6ab8f38f6e4bc 100644 --- a/apps/files_sharing/lib/Listener/RestrictInteractionListener.php +++ b/apps/files_sharing/lib/Listener/RestrictInteractionListener.php @@ -9,6 +9,10 @@ namespace OCA\Files_Sharing\Listener; +use OCA\Files\Sharing\Permission\NodeCreateSharePermissionType; +use OCA\Files\Sharing\Permission\NodeDeleteSharePermissionType; +use OCA\Files\Sharing\Permission\NodeReadSharePermissionType; +use OCA\Files\Sharing\Permission\NodeUpdateSharePermissionType; use OCA\Files_Sharing\AppInfo\Application; use OCP\Constants; use OCP\EventDispatcher\Event; @@ -56,37 +60,41 @@ public function handle(Event $event): void { throw new InteractionRestrictedException('Cannot share home folder node.', $this->l10n->t('You cannot share your home folder.')); } - if ($event->action->filesSharingPermissions !== null) { - if ($resource->getNode() instanceof File) { - if (($event->action->filesSharingPermissions & Constants::PERMISSION_DELETE) === Constants::PERMISSION_DELETE) { - throw new InteractionRestrictedException('Cannot share file node with delete permission.', $this->l10n->t('File cannot be shared with delete permission.')); - } - - if (($event->action->filesSharingPermissions & Constants::PERMISSION_CREATE) === Constants::PERMISSION_CREATE) { - throw new InteractionRestrictedException('Cannot share file node with create permission.', $this->l10n->t('File cannot be shared with create permission.')); - } + // These checks are only for files_sharing, because they operate on shares that can only contain a single source. + // With Unified Sharing, there could be multiple sources like a file and a folder in the same share. Because it grants permission on a share and not a single node, this check doesn't work. + if ($event->action->filesSharingPermissions !== null && $resource->getNode() instanceof File) { + if (($event->action->filesSharingPermissions & Constants::PERMISSION_DELETE) === Constants::PERMISSION_DELETE) { + throw new InteractionRestrictedException('Cannot share file node with delete permission.', $this->l10n->t('File cannot be shared with delete permission.')); } - foreach ($event->receivers as $receiver) { - if (!$receiver instanceof LinkReceiver - && !$receiver instanceof EmailReceiver - && ($event->action->filesSharingPermissions & Constants::PERMISSION_READ) !== Constants::PERMISSION_READ) { - throw new InteractionRestrictedException('No read permission on the share.', $this->l10n->t('File share needs at least read permission.')); - } + if (($event->action->filesSharingPermissions & Constants::PERMISSION_CREATE) === Constants::PERMISSION_CREATE) { + throw new InteractionRestrictedException('Cannot share file node with create permission.', $this->l10n->t('File cannot be shared with create permission.')); + } + } - if (($receiver instanceof LinkReceiver || $receiver instanceof EmailReceiver) - && $resource->getNode() instanceof Folder - && ($event->action->filesSharingPermissions & (Constants::PERMISSION_CREATE | Constants::PERMISSION_UPDATE | Constants::PERMISSION_DELETE)) !== 0 - && !$this->manager->shareApiLinkAllowPublicUpload()) { - throw new InteractionRestrictedException('Public upload is not allowed.', $this->l10n->t('Public upload is not allowed.')); - } + foreach ($event->receivers as $receiver) { + if (!$receiver instanceof LinkReceiver + && !$receiver instanceof EmailReceiver + && (($event->action->filesSharingPermissions !== null && ($event->action->filesSharingPermissions & Constants::PERMISSION_READ) !== Constants::PERMISSION_READ) + || ($event->action->unifiedSharingPermissions !== null && !in_array(NodeReadSharePermissionType::class, $event->action->unifiedSharingPermissions)))) { + throw new InteractionRestrictedException('No read permission on the share.', $this->l10n->t('File share needs at least read permission.')); } - if (($event->action->filesSharingPermissions & ~$resource->getNodePermissions()) !== 0) { - $path = $userFolder->getRelativePath($resource->getNode()->getPath()); - throw new InteractionRestrictedException('Cannot share node with more permissions than the node already has.', $this->l10n->t('You cannot share "%s" with more permission than you have yourself.', [$path])); + if (($receiver instanceof LinkReceiver || $receiver instanceof EmailReceiver) + && $resource->getNode() instanceof Folder + && ((($event->action->filesSharingPermissions !== null && ($event->action->filesSharingPermissions & (Constants::PERMISSION_CREATE | Constants::PERMISSION_UPDATE | Constants::PERMISSION_DELETE)) !== 0)) + || ($event->action->unifiedSharingPermissions !== null && array_intersect($event->action->unifiedSharingPermissions, [NodeCreateSharePermissionType::class, NodeUpdateSharePermissionType::class, NodeDeleteSharePermissionType::class]) !== [])) + && !$this->manager->shareApiLinkAllowPublicUpload()) { + throw new InteractionRestrictedException('Public upload is not allowed.', $this->l10n->t('Public upload is not allowed.')); } } + + // Unified Sharing may grant more permissions on the share, than a specific resources allows. + // Therefore we don't check this here, as the permission must be correctly applied later anyway. + if (($event->action->filesSharingPermissions !== null && ($event->action->filesSharingPermissions & ~$resource->getNodePermissions()) !== 0)) { + $path = $userFolder->getRelativePath($resource->getNode()->getPath()); + throw new InteractionRestrictedException('Cannot share node with more permissions than the node already has.', $this->l10n->t('You cannot share "%s" with more permission than you have yourself.', [$path])); + } } } } diff --git a/apps/files_sharing/tests/Listener/RestrictInteractionListenerTest.php b/apps/files_sharing/tests/Listener/RestrictInteractionListenerTest.php index 9eeb198867b7e..3680fcb0a2d27 100644 --- a/apps/files_sharing/tests/Listener/RestrictInteractionListenerTest.php +++ b/apps/files_sharing/tests/Listener/RestrictInteractionListenerTest.php @@ -9,6 +9,11 @@ namespace OCA\Files_Sharing\Tests\Listener; +use OC\Core\Sharing\Permission\ReshareSharePermissionType; +use OCA\Files\Sharing\Permission\NodeCreateSharePermissionType; +use OCA\Files\Sharing\Permission\NodeDeleteSharePermissionType; +use OCA\Files\Sharing\Permission\NodeReadSharePermissionType; +use OCA\Files\Sharing\Permission\NodeUpdateSharePermissionType; use OCP\Constants; use OCP\Files\IRootFolder; use OCP\Files\ISetupManager; @@ -92,6 +97,9 @@ public function testNodeResourceShareActionIncreasePermission(): void { foreach ([$fileNode, $folderNode] as $node) { $event = new RestrictInteractionEvent($this->user->getUID(), $this->user, [new NodeResource($node->getId(), $this->user->getUID(), $node)], new ShareAction(Constants::PERMISSION_READ | Constants::PERMISSION_SHARE | Constants::PERMISSION_UPDATE), []); $this->assertEquals('You cannot share "/' . $node->getName() . '" with more permission than you have yourself.', $event->isInteractionRestricted()); + + $event = new RestrictInteractionEvent($this->user->getUID(), $this->user, [new NodeResource($node->getId(), $this->user->getUID(), $node)], new ShareAction(null, [NodeReadSharePermissionType::class, ReshareSharePermissionType::class, NodeUpdateSharePermissionType::class]), []); + $this->assertFalse($event->isInteractionRestricted()); } } @@ -103,6 +111,9 @@ public function testNodeResourceShareActionIncreasePermissionFileDelete(): void $event = new RestrictInteractionEvent($this->user->getUID(), $this->user, [new NodeResource($node->getId(), $this->user->getUID(), $node)], new ShareAction(Constants::PERMISSION_READ | Constants::PERMISSION_SHARE | Constants::PERMISSION_DELETE), []); $this->assertEquals('File cannot be shared with delete permission.', $event->isInteractionRestricted()); + + $event = new RestrictInteractionEvent($this->user->getUID(), $this->user, [new NodeResource($node->getId(), $this->user->getUID(), $node)], new ShareAction(null, [NodeReadSharePermissionType::class, ReshareSharePermissionType::class, NodeDeleteSharePermissionType::class]), []); + $this->assertFalse($event->isInteractionRestricted()); } public function testNodeResourceShareActionIncreasePermissionFileCreate(): void { @@ -113,6 +124,9 @@ public function testNodeResourceShareActionIncreasePermissionFileCreate(): void $event = new RestrictInteractionEvent($this->user->getUID(), $this->user, [new NodeResource($node->getId(), $this->user->getUID(), $node)], new ShareAction(Constants::PERMISSION_READ | Constants::PERMISSION_SHARE | Constants::PERMISSION_CREATE), []); $this->assertEquals('File cannot be shared with create permission.', $event->isInteractionRestricted()); + + $event = new RestrictInteractionEvent($this->user->getUID(), $this->user, [new NodeResource($node->getId(), $this->user->getUID(), $node)], new ShareAction(null, [NodeReadSharePermissionType::class, ReshareSharePermissionType::class, NodeCreateSharePermissionType::class]), []); + $this->assertFalse($event->isInteractionRestricted()); } public function testNodeResourceShareActionFileHasDeletePermission(): void { @@ -123,6 +137,9 @@ public function testNodeResourceShareActionFileHasDeletePermission(): void { $event = new RestrictInteractionEvent($this->user->getUID(), $this->user, [new NodeResource($node->getId(), $this->user->getUID(), $node)], new ShareAction(Constants::PERMISSION_DELETE), []); $this->assertEquals('File cannot be shared with delete permission.', $event->isInteractionRestricted()); + + $event = new RestrictInteractionEvent($this->user->getUID(), $this->user, [new NodeResource($node->getId(), $this->user->getUID(), $node)], new ShareAction(null, [NodeDeleteSharePermissionType::class]), []); + $this->assertFalse($event->isInteractionRestricted()); } public function testNodeResourceShareActionFileHasCreatePermission(): void { @@ -133,6 +150,9 @@ public function testNodeResourceShareActionFileHasCreatePermission(): void { $event = new RestrictInteractionEvent($this->user->getUID(), $this->user, [new NodeResource($node->getId(), $this->user->getUID(), $node)], new ShareAction(Constants::PERMISSION_CREATE), []); $this->assertEquals('File cannot be shared with create permission.', $event->isInteractionRestricted()); + + $event = new RestrictInteractionEvent($this->user->getUID(), $this->user, [new NodeResource($node->getId(), $this->user->getUID(), $node)], new ShareAction(null, [NodeCreateSharePermissionType::class]), []); + $this->assertFalse($event->isInteractionRestricted()); } /** @psalm-suppress DeprecatedMethod The configs are not migrated to IAppConfig, so using deprecated IConfig is required for now. */ @@ -157,8 +177,13 @@ public function testNodeResourceShareActionNoLinkEmailReceiverMissingReadPermiss new RoomReceiver(''), new UserReceiver(''), ] as $receiver) { - $event = new RestrictInteractionEvent($this->user->getUID(), $this->user, [$resource], new ShareAction(Constants::PERMISSION_ALL & ~Constants::PERMISSION_READ), [$receiver]); - $this->assertEquals('File share needs at least read permission.', $event->isInteractionRestricted()); + foreach ([ + new ShareAction(Constants::PERMISSION_ALL & ~Constants::PERMISSION_READ), + new ShareAction(null, [NodeUpdateSharePermissionType::class, NodeCreateSharePermissionType::class, NodeDeleteSharePermissionType::class, ReshareSharePermissionType::class]), + ] as $action) { + $event = new RestrictInteractionEvent($this->user->getUID(), $this->user, [$resource], $action, [$receiver]); + $this->assertEquals('File share needs at least read permission.', $event->isInteractionRestricted()); + } } $config->deleteAppValue('files_sharing', 'outgoing_server2server_group_share_enabled'); @@ -181,11 +206,14 @@ public function testNodeResourceShareActionLinkEmailReceiverPublicUploadDisabled new EmailReceiver('test@example.org'), ] as $receiver) { foreach ([ - Constants::PERMISSION_CREATE, - Constants::PERMISSION_UPDATE, - Constants::PERMISSION_DELETE, - ] as $permissions) { - $event = new RestrictInteractionEvent($this->user->getUID(), $this->user, [$resource], new ShareAction($permissions), [$receiver]); + new ShareAction(Constants::PERMISSION_CREATE), + new ShareAction(null, [NodeCreateSharePermissionType::class]), + new ShareAction(Constants::PERMISSION_UPDATE), + new ShareAction(null, [NodeUpdateSharePermissionType::class]), + new ShareAction(Constants::PERMISSION_DELETE), + new ShareAction(null, [NodeDeleteSharePermissionType::class]), + ] as $action) { + $event = new RestrictInteractionEvent($this->user->getUID(), $this->user, [$resource], $action, [$receiver]); $this->assertEquals('Public upload is not allowed.', $event->isInteractionRestricted()); } } diff --git a/lib/public/Interaction/Actions/ShareAction.php b/lib/public/Interaction/Actions/ShareAction.php index 382fa4e5d167c..3e40dcccfc181 100644 --- a/lib/public/Interaction/Actions/ShareAction.php +++ b/lib/public/Interaction/Actions/ShareAction.php @@ -12,6 +12,7 @@ use OCP\AppFramework\Attribute\Consumable; use OCP\Constants; use OCP\Interaction\InteractionAction; +use OCP\Sharing\Permission\ISharePermissionType; /** * Used when a user wants to share a resource to a receiver. @@ -26,6 +27,8 @@ public function __construct( /** @var ?int-mask-of */ public ?int $filesSharingPermissions = null, + /** @var ?list> */ + public ?array $unifiedSharingPermissions = null, ) { } }