Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
use OCA\Deck\Search\CardCommentProvider;
use OCA\Deck\Search\DeckProvider;
use OCA\Deck\Service\PermissionService;
use OCA\Deck\ShareReview\ShareReviewListener;
use OCA\Deck\Sharing\DeckShareProvider;
use OCA\Deck\Sharing\Listener;
use OCA\Deck\Teams\DeckTeamResourceProvider;
Expand Down Expand Up @@ -74,6 +75,7 @@
use OCP\OCM\Events\ResourceTypeRegisterEvent;
use OCP\Server;
use OCP\Share\IManager;
use OCP\Share\ShareReview\RegisterShareReviewSourceEvent;
use OCP\User\Events\UserDeletedEvent;
use OCP\Util;
use Psr\Container\ContainerInterface;
Expand Down Expand Up @@ -189,6 +191,8 @@ public function register(IRegistrationContext $context): void {
$context->registerTeamResourceProvider(DeckTeamResourceProvider::class);

$context->registerUserMigrator(DeckMigrator::class);

$context->registerEventListener(RegisterShareReviewSourceEvent::class, ShareReviewListener::class);
}

public function registerCommentsEntity(IEventDispatcher $eventDispatcher): void {
Expand Down
28 changes: 27 additions & 1 deletion lib/Db/AclMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\Entity;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;

/** @template-extends DeckMapper<Acl> */
class AclMapper extends DeckMapper implements IPermissionMapper {
public const TABLE_NAME = 'deck_board_acl';

public function __construct(IDBConnection $db) {
parent::__construct($db, 'deck_board_acl', Acl::class);
parent::__construct($db, self::TABLE_NAME, Acl::class);
}

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

/**
* Fetch all ACL rows with their board title and owner for ShareReview.
*
* @return list<array<string, mixed>>
* @throws Exception
*/
public function findAllForShareReview(): array {
$qb = $this->db->getQueryBuilder();
$qb->select(
'a.id', 'a.board_id', 'a.type', 'a.participant',
'a.permission_edit', 'a.permission_share', 'a.permission_manage', 'a.created_at', 'a.last_modified_at'
)
->selectAlias('b.title', 'board_title')
->selectAlias('b.owner', 'board_owner')
->from(self::TABLE_NAME, 'a')
->leftJoin('a', 'deck_boards', 'b', $qb->expr()->eq('a.board_id', 'b.id'))
->orderBy('a.id', 'ASC');
$result = $qb->executeQuery();
$rows = $result->fetchAll();
$result->closeCursor();
return $rows;
}

public function insert(Entity $entity): Entity {
/** @var Acl $entity */
$now = time();
Expand Down
3 changes: 2 additions & 1 deletion lib/Db/BoardMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

/** @template-extends QBMapper<Board> */
class BoardMapper extends QBMapper implements IPermissionMapper {
public const TABLE_NAME = 'deck_boards';
/** @var CappedMemoryCache<Board[]> */
private CappedMemoryCache $userBoardCache;
/** @var CappedMemoryCache<Board> */
Expand All @@ -36,7 +37,7 @@ public function __construct(
private ICloudIdManager $cloudIdManager,
private LoggerInterface $logger,
) {
parent::__construct($db, 'deck_boards', Board::class);
parent::__construct($db, self::TABLE_NAME, Board::class);

$this->userBoardCache = new CappedMemoryCache();
$this->boardCache = new CappedMemoryCache();
Expand Down
16 changes: 16 additions & 0 deletions lib/Service/BoardService.php
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,22 @@ public function deleteAcl(int $id): ?Acl {
return $deletedAcl;
}

/**
* Delete an ACL entry on behalf of a trusted share-review operation.
*
* PERMISSION_MANAGE is intentionally not checked. The caller must verify
* operator access via ShareReviewAccessCheckEvent before invoking this
* method. All other side effects are preserved so the deletion is auditable.
*
* @throws \OCP\AppFramework\Db\DoesNotExistException if $aclId does not exist
*/
public function deleteAclForShareReview(int $aclId): void {
$acl = $this->aclMapper->find($aclId);
$this->aclMapper->delete($acl);
$this->changeHelper->boardChanged($acl->getBoardId());
$this->eventDispatcher->dispatchTyped(new AclDeletedEvent($acl));
}

public function leave(int $boardId): ?Acl {
if ($this->permissionService->userIsBoardOwner($boardId)) {
throw new BadRequestException('Board owner cannot leave board');
Expand Down
27 changes: 27 additions & 0 deletions lib/ShareReview/ShareReviewListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Deck\ShareReview;

use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Share\ShareReview\RegisterShareReviewSourceEvent;

/** @template-implements IEventListener<RegisterShareReviewSourceEvent> */
class ShareReviewListener implements IEventListener {
public function __construct() {
}

public function handle(Event $event): void {
if (!$event instanceof RegisterShareReviewSourceEvent) {
return;
}
$event->registerSource(ShareReviewSource::class);
}
}
129 changes: 129 additions & 0 deletions lib/ShareReview/ShareReviewSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Deck\ShareReview;

use OCA\Deck\Db\Acl;
use OCA\Deck\Db\AclMapper;
use OCA\Deck\Service\BoardService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\Constants;
use OCP\DB\Exception;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IL10N;
use OCP\Share\IShare;
use OCP\Share\ShareReview\Events\ShareReviewAccessCheckEvent;
use OCP\Share\ShareReview\IShareReviewSource;
use OCP\Share\ShareReview\ShareReviewEntry;
use Psr\Log\LoggerInterface;

class ShareReviewSource implements IShareReviewSource {

private const PERMISSION_MANAGE = 32;

public function __construct(
private readonly AclMapper $aclMapper,
private readonly LoggerInterface $logger,
private readonly BoardService $boardService,
private readonly IEventDispatcher $eventDispatcher,
private readonly IL10N $l,
) {
}

public function getName(): string {
return 'Deck';
}

/**
* @return list<ShareReviewEntry>
*/
public function getShares(): array {
try {
$rawShares = $this->aclMapper->findAllForShareReview();
} catch (Exception $e) {
$this->logger->error('Deck ShareReview: failed to fetch shares: {message}', ['message' => $e->getMessage()]);
return [];
}
return array_map(
fn (array $share) => $this->buildEntry($share),
$rawShares,
);
}

public function deleteShare(string $shareId): bool {
if (!is_numeric($shareId)) {
return false;
}

$event = new ShareReviewAccessCheckEvent('Deck', $shareId);
$this->eventDispatcher->dispatchTyped($event);

if (!$event->isHandled() || !$event->isGranted()) {
return false;
}

try {
$this->boardService->deleteAclForShareReview((int)$shareId);
return true;
} catch (DoesNotExistException) {
return false;
}
}

/** @param array<string, mixed> $share */
private function buildEntry(array $share): ShareReviewEntry {
return new ShareReviewEntry(
id: (string)$share['id'],
object: $this->resolveObjectName($share),
initiator: (string)$share['board_owner'],
type: $this->mapParticipantType((int)$share['type']),
recipient: (string)$share['participant'],
permissions: $this->computePermissions($share),
time: date('Y-m-d H:i:s', max((int)$share['created_at'], (int)$share['last_modified_at'])),
);
}

/** @param array<string, mixed> $share */
private function resolveObjectName(array $share): string {
$title = (string)($share['board_title'] ?? '');
$boardId = (int)($share['board_id'] ?? $share['id']);
$label = $title !== '' ? $title : $this->l->t('Board %d', [$boardId]);
return $this->l->t('%s (Board)', [$label]);
}

private function mapParticipantType(int $type): int {
return match($type) {
Acl::PERMISSION_TYPE_USER => IShare::TYPE_USER,
Acl::PERMISSION_TYPE_GROUP => IShare::TYPE_GROUP,
Acl::PERMISSION_TYPE_REMOTE => IShare::TYPE_REMOTE,
Acl::PERMISSION_TYPE_CIRCLE => IShare::TYPE_CIRCLE,
default => $this->fallbackParticipantType($type),
};
}

private function fallbackParticipantType(int $type): int {
$this->logger->warning('Deck ShareReview: unknown ACL participant type {type}, defaulting to user share', ['type' => $type]);
return IShare::TYPE_USER;
}

/** @param array<string, mixed> $share */
private function computePermissions(array $share): int {
$permissions = Constants::PERMISSION_READ;
if ($share['permission_edit']) {
$permissions |= Constants::PERMISSION_UPDATE | Constants::PERMISSION_CREATE | Constants::PERMISSION_DELETE;
}
if ($share['permission_share']) {
$permissions |= Constants::PERMISSION_SHARE;
}
if ($share['permission_manage']) {
$permissions |= self::PERMISSION_MANAGE;
}
return $permissions;
}
}
4 changes: 4 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@

require_once __DIR__ . '/../../../tests/bootstrap.php';
require_once __DIR__ . '/../appinfo/autoload.php';

if (!interface_exists('OCP\Share\ShareReview\IShareReviewSource')) {
require_once __DIR__ . '/unit/ShareReview/Stubs.php';
}
2 changes: 1 addition & 1 deletion tests/phpunit.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0"?>
<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">
<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">
<coverage>
<include>
<directory suffix=".php">./../lib</directory>
Expand Down
Loading
Loading