Skip to content

Commit e1b63bf

Browse files
feat(sharereview): expose deck shares to share-review apps via OCP\Share\ShareReview
Implement IShareReviewSource listing all deck shares with their capabilities mapped to ShareReviewPermission entries, gate deletions behind the ShareReviewAccessCheckEvent authorization check including revocation of linked uploaded-files shares, and register the source via RegisterShareReviewSourceEvent. Assisted-by: Claude Code:claude-fable-5 Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent 688ba8a commit e1b63bf

12 files changed

Lines changed: 1470 additions & 730 deletions

File tree

lib/AppInfo/Application.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
use OCA\Deck\Search\CardCommentProvider;
4848
use OCA\Deck\Search\DeckProvider;
4949
use OCA\Deck\Service\PermissionService;
50+
use OCA\Deck\ShareReview\ShareReviewListener;
5051
use OCA\Deck\Sharing\DeckShareProvider;
5152
use OCA\Deck\Sharing\Listener;
5253
use OCA\Deck\Teams\DeckTeamResourceProvider;
@@ -74,6 +75,7 @@
7475
use OCP\OCM\Events\ResourceTypeRegisterEvent;
7576
use OCP\Server;
7677
use OCP\Share\IManager;
78+
use OCP\Share\ShareReview\RegisterShareReviewSourceEvent;
7779
use OCP\User\Events\UserDeletedEvent;
7880
use OCP\Util;
7981
use Psr\Container\ContainerInterface;
@@ -189,6 +191,8 @@ public function register(IRegistrationContext $context): void {
189191
$context->registerTeamResourceProvider(DeckTeamResourceProvider::class);
190192

191193
$context->registerUserMigrator(DeckMigrator::class);
194+
195+
$context->registerEventListener(RegisterShareReviewSourceEvent::class, ShareReviewListener::class);
192196
}
193197

194198
public function registerCommentsEntity(IEventDispatcher $eventDispatcher): void {

lib/Db/AclMapper.php

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@
1010
use OCP\AppFramework\Db\DoesNotExistException;
1111
use OCP\AppFramework\Db\Entity;
1212
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
13+
use OCP\DB\Exception;
1314
use OCP\DB\QueryBuilder\IQueryBuilder;
1415
use OCP\IDBConnection;
1516

1617
/** @template-extends DeckMapper<Acl> */
1718
class AclMapper extends DeckMapper implements IPermissionMapper {
19+
public const TABLE_NAME = 'deck_board_acl';
20+
1821
public function __construct(IDBConnection $db) {
19-
parent::__construct($db, 'deck_board_acl', Acl::class);
22+
parent::__construct($db, self::TABLE_NAME, Acl::class);
2023
}
2124

2225
public function findByAccessToken(string $accessToken) {
@@ -129,6 +132,29 @@ public function findByType(int $type): array {
129132
return $this->findEntities($qb);
130133
}
131134

135+
/**
136+
* Fetch all ACL rows with their board title and owner for ShareReview.
137+
*
138+
* @return list<array<string, mixed>>
139+
* @throws Exception
140+
*/
141+
public function findAllForShareReview(): array {
142+
$qb = $this->db->getQueryBuilder();
143+
$qb->select(
144+
'a.id', 'a.board_id', 'a.type', 'a.participant',
145+
'a.permission_edit', 'a.permission_share', 'a.permission_manage', 'a.created_at', 'a.last_modified_at'
146+
)
147+
->selectAlias('b.title', 'board_title')
148+
->selectAlias('b.owner', 'board_owner')
149+
->from(self::TABLE_NAME, 'a')
150+
->leftJoin('a', 'deck_boards', 'b', $qb->expr()->eq('a.board_id', 'b.id'))
151+
->orderBy('a.id', 'ASC');
152+
$result = $qb->executeQuery();
153+
$rows = $result->fetchAll();
154+
$result->closeCursor();
155+
return $rows;
156+
}
157+
132158
public function insert(Entity $entity): Entity {
133159
/** @var Acl $entity */
134160
$now = time();

lib/Db/BoardMapper.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
/** @template-extends QBMapper<Board> */
2222
class BoardMapper extends QBMapper implements IPermissionMapper {
23+
public const TABLE_NAME = 'deck_boards';
2324
/** @var CappedMemoryCache<Board[]> */
2425
private CappedMemoryCache $userBoardCache;
2526
/** @var CappedMemoryCache<Board> */
@@ -36,7 +37,7 @@ public function __construct(
3637
private ICloudIdManager $cloudIdManager,
3738
private LoggerInterface $logger,
3839
) {
39-
parent::__construct($db, 'deck_boards', Board::class);
40+
parent::__construct($db, self::TABLE_NAME, Board::class);
4041

4142
$this->userBoardCache = new CappedMemoryCache();
4243
$this->boardCache = new CappedMemoryCache();

lib/Service/BoardService.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,22 @@ public function deleteAcl(int $id): ?Acl {
515515
return $deletedAcl;
516516
}
517517

518+
/**
519+
* Delete an ACL entry on behalf of a trusted share-review operation.
520+
*
521+
* PERMISSION_MANAGE is intentionally not checked. The caller must verify
522+
* operator access via ShareReviewAccessCheckEvent before invoking this
523+
* method. All other side effects are preserved so the deletion is auditable.
524+
*
525+
* @throws \OCP\AppFramework\Db\DoesNotExistException if $aclId does not exist
526+
*/
527+
public function deleteAclForShareReview(int $aclId): void {
528+
$acl = $this->aclMapper->find($aclId);
529+
$this->aclMapper->delete($acl);
530+
$this->changeHelper->boardChanged($acl->getBoardId());
531+
$this->eventDispatcher->dispatchTyped(new AclDeletedEvent($acl));
532+
}
533+
518534
public function leave(int $boardId): ?Acl {
519535
if ($this->permissionService->userIsBoardOwner($boardId)) {
520536
throw new BadRequestException('Board owner cannot leave board');
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Deck\ShareReview;
11+
12+
use OCP\EventDispatcher\Event;
13+
use OCP\EventDispatcher\IEventListener;
14+
use OCP\Share\ShareReview\RegisterShareReviewSourceEvent;
15+
16+
/** @template-implements IEventListener<RegisterShareReviewSourceEvent> */
17+
class ShareReviewListener implements IEventListener {
18+
public function __construct() {
19+
}
20+
21+
public function handle(Event $event): void {
22+
if (!$event instanceof RegisterShareReviewSourceEvent) {
23+
return;
24+
}
25+
$event->registerSource(ShareReviewSource::class);
26+
}
27+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Deck\ShareReview;
11+
12+
use OCA\Deck\Db\Acl;
13+
use OCA\Deck\Db\AclMapper;
14+
use OCA\Deck\Service\BoardService;
15+
use OCP\AppFramework\Db\DoesNotExistException;
16+
use OCP\DB\Exception;
17+
use OCP\EventDispatcher\IEventDispatcher;
18+
use OCP\IL10N;
19+
use OCP\Share\IShare;
20+
use OCP\Share\ShareReview\Events\ShareReviewAccessCheckEvent;
21+
use OCP\Share\ShareReview\IShareReviewSource;
22+
use OCP\Share\ShareReview\ShareReviewEntry;
23+
use OCP\Share\ShareReview\ShareReviewPermission;
24+
use Psr\Log\LoggerInterface;
25+
26+
class ShareReviewSource implements IShareReviewSource {
27+
28+
public const PERMISSION_READ = 'deck:read';
29+
public const PERMISSION_EDIT = 'deck:edit';
30+
public const PERMISSION_SHARE = 'deck:share';
31+
public const PERMISSION_MANAGE = 'deck:manage';
32+
33+
/** @var array<string, ShareReviewPermission>|null */
34+
private ?array $permissionCatalog = null;
35+
36+
public function __construct(
37+
private readonly AclMapper $aclMapper,
38+
private readonly LoggerInterface $logger,
39+
private readonly BoardService $boardService,
40+
private readonly IEventDispatcher $eventDispatcher,
41+
private readonly IL10N $l,
42+
) {
43+
}
44+
45+
public function getName(): string {
46+
return 'Deck';
47+
}
48+
49+
/**
50+
* @return list<ShareReviewEntry>
51+
*/
52+
public function getShares(): array {
53+
try {
54+
$rawShares = $this->aclMapper->findAllForShareReview();
55+
} catch (Exception $e) {
56+
$this->logger->error('Deck ShareReview: failed to fetch shares: {message}', ['message' => $e->getMessage()]);
57+
return [];
58+
}
59+
return array_map(
60+
fn (array $share) => $this->buildEntry($share),
61+
$rawShares,
62+
);
63+
}
64+
65+
public function deleteShare(string $shareId): bool {
66+
if (!is_numeric($shareId)) {
67+
return false;
68+
}
69+
70+
$event = new ShareReviewAccessCheckEvent('Deck', $shareId);
71+
$this->eventDispatcher->dispatchTyped($event);
72+
73+
if (!$event->isHandled() || !$event->isGranted()) {
74+
return false;
75+
}
76+
77+
try {
78+
$this->boardService->deleteAclForShareReview((int)$shareId);
79+
return true;
80+
} catch (DoesNotExistException) {
81+
return false;
82+
}
83+
}
84+
85+
/** @param array<string, mixed> $share */
86+
private function buildEntry(array $share): ShareReviewEntry {
87+
return new ShareReviewEntry(
88+
id: (string)$share['id'],
89+
object: $this->resolveObjectName($share),
90+
initiator: (string)$share['board_owner'],
91+
type: $this->mapParticipantType((int)$share['type']),
92+
recipient: (string)$share['participant'],
93+
lastModifiedTimestamp: max((int)$share['created_at'], (int)$share['last_modified_at']),
94+
permissions: $this->buildPermissions($share),
95+
);
96+
}
97+
98+
/** @param array<string, mixed> $share */
99+
private function resolveObjectName(array $share): string {
100+
$title = (string)($share['board_title'] ?? '');
101+
$boardId = (int)($share['board_id'] ?? $share['id']);
102+
$label = $title !== '' ? $title : $this->l->t('Board %d', [$boardId]);
103+
return $this->l->t('%s (Board)', [$label]);
104+
}
105+
106+
private function mapParticipantType(int $type): int {
107+
return match($type) {
108+
Acl::PERMISSION_TYPE_USER => IShare::TYPE_USER,
109+
Acl::PERMISSION_TYPE_GROUP => IShare::TYPE_GROUP,
110+
Acl::PERMISSION_TYPE_REMOTE => IShare::TYPE_REMOTE,
111+
Acl::PERMISSION_TYPE_CIRCLE => IShare::TYPE_CIRCLE,
112+
default => $this->fallbackParticipantType($type),
113+
};
114+
}
115+
116+
private function fallbackParticipantType(int $type): int {
117+
$this->logger->warning('Deck ShareReview: unknown ACL participant type {type}, defaulting to user share', ['type' => $type]);
118+
return IShare::TYPE_USER;
119+
}
120+
121+
/**
122+
* @param array<string, mixed> $share
123+
* @return list<ShareReviewPermission>
124+
*/
125+
private function buildPermissions(array $share): array {
126+
$catalog = $this->permissionCatalog();
127+
$permissions = [$catalog[self::PERMISSION_READ]];
128+
if ($share['permission_edit']) {
129+
$permissions[] = $catalog[self::PERMISSION_EDIT];
130+
}
131+
if ($share['permission_share']) {
132+
$permissions[] = $catalog[self::PERMISSION_SHARE];
133+
}
134+
if ($share['permission_manage']) {
135+
$permissions[] = $catalog[self::PERMISSION_MANAGE];
136+
}
137+
return $permissions;
138+
}
139+
140+
/**
141+
* The permission objects are immutable and identical for every share row,
142+
* so they are built once per request instead of once per row.
143+
*
144+
* All permission IDs are namespaced to this app, and labels and hints are
145+
* translated from this app's own catalog — the app owning a permission
146+
* also owns its wording in every language.
147+
*
148+
* @return array<string, ShareReviewPermission>
149+
*/
150+
private function permissionCatalog(): array {
151+
return $this->permissionCatalog ??= [
152+
self::PERMISSION_READ => new ShareReviewPermission(self::PERMISSION_READ, $this->l->t('Read'), priority: 80),
153+
self::PERMISSION_EDIT => new ShareReviewPermission(self::PERMISSION_EDIT, $this->l->t('Edit'), $this->l->t('Create, update and delete cards'), 70),
154+
self::PERMISSION_SHARE => new ShareReviewPermission(self::PERMISSION_SHARE, $this->l->t('Re-share'), priority: 40),
155+
self::PERMISSION_MANAGE => new ShareReviewPermission(self::PERMISSION_MANAGE, $this->l->t('Manage board'), $this->l->t('Administer participants and board settings'), 30),
156+
];
157+
}
158+
}

tests/bootstrap.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,7 @@
2424

2525
require_once __DIR__ . '/../../../tests/bootstrap.php';
2626
require_once __DIR__ . '/../appinfo/autoload.php';
27+
28+
if (!interface_exists('OCP\Share\ShareReview\IShareReviewSource')) {
29+
require_once __DIR__ . '/unit/ShareReview/Stubs.php';
30+
}

tests/phpunit.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<?xml version="1.0"?>
2-
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="../../../tests/bootstrap.php" colors="true" convertDeprecationsToExceptions="true" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="bootstrap.php" colors="true" convertDeprecationsToExceptions="true" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
33
<coverage>
44
<include>
55
<directory suffix=".php">./../lib</directory>

0 commit comments

Comments
 (0)