diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 0db36117e82df..99f763fea04a2 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -891,6 +891,11 @@ 'OCP\\Share\\IShareProviderSupportsAccept' => $baseDir . '/lib/public/Share/IShareProviderSupportsAccept.php', 'OCP\\Share\\IShareProviderSupportsAllSharesInFolder' => $baseDir . '/lib/public/Share/IShareProviderSupportsAllSharesInFolder.php', 'OCP\\Share\\IShareProviderWithNotification' => $baseDir . '/lib/public/Share/IShareProviderWithNotification.php', + 'OCP\\Share\\ShareReview\\Events\\ShareReviewAccessCheckEvent' => $baseDir . '/lib/public/Share/ShareReview/Events/ShareReviewAccessCheckEvent.php', + 'OCP\\Share\\ShareReview\\IShareReviewSource' => $baseDir . '/lib/public/Share/ShareReview/IShareReviewSource.php', + '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\\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', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index b0d0907ac8bd2..6f6b7702e7164 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -932,6 +932,11 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Share\\IShareProviderSupportsAccept' => __DIR__ . '/../../..' . '/lib/public/Share/IShareProviderSupportsAccept.php', 'OCP\\Share\\IShareProviderSupportsAllSharesInFolder' => __DIR__ . '/../../..' . '/lib/public/Share/IShareProviderSupportsAllSharesInFolder.php', 'OCP\\Share\\IShareProviderWithNotification' => __DIR__ . '/../../..' . '/lib/public/Share/IShareProviderWithNotification.php', + 'OCP\\Share\\ShareReview\\Events\\ShareReviewAccessCheckEvent' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/Events/ShareReviewAccessCheckEvent.php', + 'OCP\\Share\\ShareReview\\IShareReviewSource' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/IShareReviewSource.php', + '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\\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', diff --git a/lib/public/Share/ShareReview/Events/ShareReviewAccessCheckEvent.php b/lib/public/Share/ShareReview/Events/ShareReviewAccessCheckEvent.php new file mode 100644 index 0000000000000..38f998ab156bb --- /dev/null +++ b/lib/public/Share/ShareReview/Events/ShareReviewAccessCheckEvent.php @@ -0,0 +1,165 @@ +dispatcher->dispatchTyped($event); + * if (!$event->isHandled() || !$event->isGranted()) { + * return false; // default-deny: no listener means no access + * } + * // ... actually delete the share ... + * } + * + * Listened to by: the share-review app. Its listener decides whether the + * current user is an authorized share-review operator (e.g. the app is + * enabled for the user) and answers with grantAccess() or denyAccess(): + * + * public function handle(Event $event): void { + * if (!$event instanceof ShareReviewAccessCheckEvent) { + * return; + * } + * if ($this->isShareReviewOperator()) { + * $event->grantAccess(); + * } else { + * $event->denyAccess('User is not a share-review operator.'); + * } + * } + * + * Apps that merely expose shares must not listen to this event; answering it + * is the responsibility of the share-review app that triggered the deletion. + * + * Semantics: + * - Default-deny: if no listener responds (isHandled() is false, e.g. no + * share-review app is installed), the dispatcher must not delete the share. + * - Deny wins: once denyAccess() is called, further grantAccess() calls are + * ignored and propagation is stopped immediately. + * - Multiple grants are harmless; the last listener to deny is authoritative. + * + * @since 34.0.2 + */ +#[Consumable(since: '34.0.2')] +class ShareReviewAccessCheckEvent extends Event { + + private bool $handled = false; + private bool $granted = false; + private ?string $reason = null; + + /** + * @param string $sourceName Stable, non-translated identifier for the app + * registering the share source (e.g. 'Deck', 'Tables'). + * @param string $shareId App-internal identifier of the share being deleted. + * + * @since 34.0.2 + */ + public function __construct( + private readonly string $sourceName, + private readonly string $shareId, + ) { + parent::__construct(); + } + + /** + * Stable, non-translated identifier of the app that owns this share source. + * + * @since 34.0.2 + */ + public function getSourceName(): string { + return $this->sourceName; + } + + /** + * App-internal identifier of the share being deleted. + * + * @since 34.0.2 + */ + public function getShareId(): string { + return $this->shareId; + } + + /** + * Grant access to delete the share. + * + * Has no effect if denyAccess() was already called on this event — deny wins. + * + * @since 34.0.2 + */ + public function grantAccess(): void { + if ($this->handled && !$this->granted) { + return; // deny wins — a prior denyAccess() cannot be escalated to a grant + } + $this->handled = true; + $this->granted = true; + } + + /** + * Deny access and provide a human-readable reason. + * + * Stops event propagation immediately — no further listeners will run. + * + * @since 34.0.2 + */ + public function denyAccess(string $reason): void { + $this->handled = true; + $this->granted = false; + $this->reason = $reason; + $this->stopPropagation(); + } + + /** + * Whether any listener has responded to this event. + * + * @since 34.0.2 + */ + public function isHandled(): bool { + return $this->handled; + } + + /** + * Whether access was granted. + * + * @since 34.0.2 + */ + public function isGranted(): bool { + return $this->granted; + } + + /** + * Human-readable denial reason, or null if access was granted or the event + * has not been handled yet. + * + * @since 34.0.2 + */ + public function getReason(): ?string { + return $this->reason; + } +} diff --git a/lib/public/Share/ShareReview/IShareReviewSource.php b/lib/public/Share/ShareReview/IShareReviewSource.php new file mode 100644 index 0000000000000..0e958f91c2020 --- /dev/null +++ b/lib/public/Share/ShareReview/IShareReviewSource.php @@ -0,0 +1,48 @@ + + * + * @since 34.0.2 + */ + public function getShares(): array; + + /** + * Delete an app-specific share. + * + * @since 34.0.2 + */ + public function deleteShare(string $shareId): bool; +} diff --git a/lib/public/Share/ShareReview/RegisterShareReviewSourceEvent.php b/lib/public/Share/ShareReview/RegisterShareReviewSourceEvent.php new file mode 100644 index 0000000000000..929277de64bc1 --- /dev/null +++ b/lib/public/Share/ShareReview/RegisterShareReviewSourceEvent.php @@ -0,0 +1,46 @@ +> */ + private array $sources = []; + + /** + * @param class-string $source + * @since 34.0.2 + */ + public function registerSource(string $source): void { + $this->sources[] = $source; + } + + /** + * @return array> + * @since 34.0.2 + */ + public function getSources(): array { + return $this->sources; + } +} diff --git a/lib/public/Share/ShareReview/ShareReviewEntry.php b/lib/public/Share/ShareReview/ShareReviewEntry.php new file mode 100644 index 0000000000000..664b37af0450d --- /dev/null +++ b/lib/public/Share/ShareReview/ShareReviewEntry.php @@ -0,0 +1,64 @@ + $permissions Permissions granted by + * the share. An empty list + * means the share grants + * nothing beyond existing. + * @param string $action Optional deletion identifier override. An empty + * string means $id is used. + * @param bool $hasPassword Whether the share is password protected. Never + * the password itself. + * @param int|null $expirationTimestamp Optional expiration Unix timestamp + * of the share. + * @param string|null $parent Optional identifier of the parent share. + * + * @since 34.0.2 + */ + public function __construct( + public readonly string $id, + public readonly string $object, + public readonly string $initiator, + public readonly int $type, + public readonly string $recipient, + public readonly int $lastModifiedTimestamp, + public readonly array $permissions = [], + public readonly string $action = '', + public readonly bool $hasPassword = false, + public readonly ?int $expirationTimestamp = null, + public readonly ?string $parent = null, + ) { + } +} diff --git a/lib/public/Share/ShareReview/ShareReviewPermission.php b/lib/public/Share/ShareReview/ShareReviewPermission.php new file mode 100644 index 0000000000000..fd17bb8029f68 --- /dev/null +++ b/lib/public/Share/ShareReview/ShareReviewPermission.php @@ -0,0 +1,77 @@ +:", e.g. "deck:manage"). Apps + * never share identifiers, even for permissions with + * the same name. Consumers must not parse or interpret + * the identifier beyond equality checks (e.g. for icon + * or translation lookup). + * @param string $displayName Localized, human readable label. + * @param string|null $hint Optional localized description. + * @param int $priority 1-100, higher is listed first. + * + * @since 34.0.2 + */ + public function __construct( + public readonly string $id, + public readonly string $displayName, + public readonly ?string $hint = null, + public readonly int $priority = 50, + ) { + } +} diff --git a/tests/lib/Share20/ShareReview/Events/ShareReviewAccessCheckEventTest.php b/tests/lib/Share20/ShareReview/Events/ShareReviewAccessCheckEventTest.php new file mode 100644 index 0000000000000..61e049f16c5ab --- /dev/null +++ b/tests/lib/Share20/ShareReview/Events/ShareReviewAccessCheckEventTest.php @@ -0,0 +1,96 @@ +makeEvent(); + + $this->assertFalse($event->isHandled()); + $this->assertFalse($event->isGranted()); + $this->assertNull($event->getReason()); + } + + public function testConstructorPayload(): void { + $event = new ShareReviewAccessCheckEvent('Deck', '99'); + + $this->assertSame('Deck', $event->getSourceName()); + $this->assertSame('99', $event->getShareId()); + } + + public function testGrantAccess(): void { + $event = $this->makeEvent(); + $event->grantAccess(); + + $this->assertTrue($event->isHandled()); + $this->assertTrue($event->isGranted()); + $this->assertNull($event->getReason()); + $this->assertFalse($event->isPropagationStopped()); + } + + public function testDenyAccess(): void { + $event = $this->makeEvent(); + $event->denyAccess('not in group'); + + $this->assertTrue($event->isHandled()); + $this->assertFalse($event->isGranted()); + $this->assertSame('not in group', $event->getReason()); + } + + public function testDenyStopsPropagation(): void { + $event = $this->makeEvent(); + $event->denyAccess('no access'); + + $this->assertTrue($event->isPropagationStopped()); + } + + public function testGrantDoesNotStopPropagation(): void { + $event = $this->makeEvent(); + $event->grantAccess(); + + $this->assertFalse($event->isPropagationStopped()); + } + + public function testGrantThenDenyIsDenied(): void { + $event = $this->makeEvent(); + $event->grantAccess(); + $event->denyAccess('revoked'); + + $this->assertFalse($event->isGranted()); + $this->assertSame('revoked', $event->getReason()); + $this->assertTrue($event->isPropagationStopped()); + } + + public function testDenyThenGrantRemainesDenied(): void { + $event = $this->makeEvent(); + $event->denyAccess('not allowed'); + $event->grantAccess(); // must be ignored — deny wins + + $this->assertFalse($event->isGranted()); + $this->assertSame('not allowed', $event->getReason()); + } + + public function testMultipleGrantsAreIdempotent(): void { + $event = $this->makeEvent(); + $event->grantAccess(); + $event->grantAccess(); + + $this->assertTrue($event->isGranted()); + $this->assertFalse($event->isPropagationStopped()); + } +} diff --git a/tests/lib/Share20/ShareReview/RegisterShareReviewSourceEventTest.php b/tests/lib/Share20/ShareReview/RegisterShareReviewSourceEventTest.php new file mode 100644 index 0000000000000..80acbca379228 --- /dev/null +++ b/tests/lib/Share20/ShareReview/RegisterShareReviewSourceEventTest.php @@ -0,0 +1,42 @@ +assertSame([], $event->getSources()); + } + + public function testRegisterSource(): void { + $sourceClass = $this->createMock(IShareReviewSource::class)::class; + + $event = new RegisterShareReviewSourceEvent(); + $event->registerSource($sourceClass); + + $this->assertSame([$sourceClass], $event->getSources()); + } + + public function testRegisterSourceKeepsDuplicates(): void { + $sourceClass = $this->createMock(IShareReviewSource::class)::class; + + $event = new RegisterShareReviewSourceEvent(); + $event->registerSource($sourceClass); + $event->registerSource($sourceClass); + + $this->assertSame([$sourceClass, $sourceClass], $event->getSources()); + } +} diff --git a/tests/lib/Share20/ShareReview/ShareReviewEntryTest.php b/tests/lib/Share20/ShareReview/ShareReviewEntryTest.php new file mode 100644 index 0000000000000..8db7458b72fff --- /dev/null +++ b/tests/lib/Share20/ShareReview/ShareReviewEntryTest.php @@ -0,0 +1,69 @@ +assertSame('42', $entry->id); + $this->assertSame('Board "Roadmap"', $entry->object); + $this->assertSame('alice', $entry->initiator); + $this->assertSame(IShare::TYPE_USER, $entry->type); + $this->assertSame('bob', $entry->recipient); + $this->assertSame(1783764000, $entry->lastModifiedTimestamp); + $this->assertSame($permissions, $entry->permissions); + $this->assertSame('board-share-42', $entry->action); + $this->assertTrue($entry->hasPassword); + $this->assertSame(1785837600, $entry->expirationTimestamp); + $this->assertSame('23', $entry->parent); + } + + public function testDefaults(): void { + $entry = new ShareReviewEntry( + id: '42', + object: '/folder/file.txt', + initiator: 'alice', + type: IShare::TYPE_LINK, + recipient: 'sToKeN', + lastModifiedTimestamp: 0, + ); + + $this->assertSame(0, $entry->lastModifiedTimestamp); + $this->assertSame([], $entry->permissions); + $this->assertSame('', $entry->action); + $this->assertFalse($entry->hasPassword); + $this->assertNull($entry->expirationTimestamp); + $this->assertNull($entry->parent); + } +} diff --git a/tests/lib/Share20/ShareReview/ShareReviewPermissionTest.php b/tests/lib/Share20/ShareReview/ShareReviewPermissionTest.php new file mode 100644 index 0000000000000..23b55c782085b --- /dev/null +++ b/tests/lib/Share20/ShareReview/ShareReviewPermissionTest.php @@ -0,0 +1,47 @@ +assertSame('deck:manage', $permission->id); + $this->assertSame('Manage board', $permission->displayName); + $this->assertSame('Administer participants and board settings', $permission->hint); + $this->assertSame(30, $permission->priority); + } + + public function testDefaults(): void { + $permission = new ShareReviewPermission(ShareReviewPermission::FILES_READ, 'Read'); + + $this->assertSame('files:read', $permission->id); + $this->assertSame('Read', $permission->displayName); + $this->assertNull($permission->hint); + $this->assertSame(50, $permission->priority); + } + + public function testFilesIdentifiers(): void { + $this->assertSame('files:read', ShareReviewPermission::FILES_READ); + $this->assertSame('files:update', ShareReviewPermission::FILES_UPDATE); + $this->assertSame('files:create', ShareReviewPermission::FILES_CREATE); + $this->assertSame('files:delete', ShareReviewPermission::FILES_DELETE); + $this->assertSame('files:reshare', ShareReviewPermission::FILES_RESHARE); + } +}