diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 83b5dbeb66a9e..33c6e2254bd44 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -875,6 +875,9 @@ '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\\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 1b0383a23142e..f311c68dc8668 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -916,6 +916,9 @@ 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\\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..218e4bf28943e --- /dev/null +++ b/lib/public/Share/ShareReview/IShareReviewSource.php @@ -0,0 +1,71 @@ + Each share contains: + * id: The unique app-specific identifier for the share, passed to deleteShare(). + * object: The name or title of the object, such as a file path or report name. + * initiator: The user ID of the initiator. + * type: The OCP\Share\IShare type of the share. + * recipient: The user ID of the owner or the token of a link. + * permissions: The permissions level. Use 1 as the default if not set. + * time: The creation time. Use '1970-01-01 01:00:00' as the default if null. + * action: Optional deletion identifier override. Use an empty string to use id. + * password: Whether the share is password protected. Do not return the password itself. + * expiration: Optional expiration date displayed for the share. + * + * @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/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..a50f63cef281c --- /dev/null +++ b/tests/lib/Share20/ShareReview/RegisterShareReviewSourceEventTest.php @@ -0,0 +1,65 @@ + */ + private function makeSourceClass(string $name): string { + $source = new class($name) implements IShareReviewSource { + public function __construct( + private readonly string $name = '', + ) { + } + + public function getName(): string { + return $this->name; + } + + public function getShares(): array { + return []; + } + + public function deleteShare(string $shareId): bool { + return false; + } + }; + return $source::class; + } + + public function testNoSourcesRegistered(): void { + $event = new RegisterShareReviewSourceEvent(); + + $this->assertSame([], $event->getSources()); + } + + public function testRegisterSource(): void { + $sourceClass = $this->makeSourceClass('MyApp'); + + $event = new RegisterShareReviewSourceEvent(); + $event->registerSource($sourceClass); + + $this->assertSame([$sourceClass], $event->getSources()); + } + + public function testRegisterSourceKeepsDuplicates(): void { + $sourceClass = $this->makeSourceClass('MyApp'); + + $event = new RegisterShareReviewSourceEvent(); + $event->registerSource($sourceClass); + $event->registerSource($sourceClass); + + $this->assertSame([$sourceClass, $sourceClass], $event->getSources()); + } +}