diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 0db36117e82df..8be668ffd03b0 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -891,6 +891,10 @@ '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\\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..c027a3de3e375 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -932,6 +932,10 @@ 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\\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..6a30e81c24708 --- /dev/null +++ b/lib/public/Share/ShareReview/ShareReviewEntry.php @@ -0,0 +1,57 @@ +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..b4fa010f8f3c2 --- /dev/null +++ b/tests/lib/Share20/ShareReview/ShareReviewEntryTest.php @@ -0,0 +1,65 @@ +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(31, $entry->permissions); + $this->assertSame('2026-07-07 12:00:00', $entry->time); + $this->assertSame('board-share-42', $entry->action); + $this->assertSame(1783764000, $entry->timestamp); + $this->assertTrue($entry->password); + $this->assertSame('2026-08-01', $entry->expiration); + $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', + ); + + $this->assertSame(1, $entry->permissions); + $this->assertSame('1970-01-01 01:00:00', $entry->time); + $this->assertSame('', $entry->action); + $this->assertNull($entry->timestamp); + $this->assertFalse($entry->password); + $this->assertNull($entry->expiration); + $this->assertNull($entry->parent); + } +}