Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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,10 +47,12 @@
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;
use OCA\Deck\UserMigration\DeckMigrator;
use OCA\ShareReview\Sources\SourceEvent;
use OCA\Text\Event\LoadEditor;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
Expand Down Expand Up @@ -189,6 +191,8 @@ public function register(IRegistrationContext $context): void {
$context->registerTeamResourceProvider(DeckTeamResourceProvider::class);

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

$context->registerEventListener(SourceEvent::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 OCA\ShareReview\Sources\SourceEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;

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

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

class ShareReviewShare {
public function __construct(
private readonly int $id,
private readonly string $object,
private readonly string $initiator,
private readonly int $type,
private readonly string $recipient,
private readonly int $permissions,
private readonly string $time,
) {
}

/** @return array{id: int, object: string, initiator: string, type: int, recipient: string, permissions: int, password: bool, time: string, action: string} */
public function toArray(): array {
return [
'id' => $this->id,
'object' => $this->object,
'initiator' => $this->initiator,
'type' => $this->type,
'recipient' => $this->recipient,
'permissions' => $this->permissions,
'password' => false,
'time' => $this->time,
'action' => '',
];
}
}
128 changes: 128 additions & 0 deletions lib/ShareReview/ShareReviewSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?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 OCA\ShareReview\Sources\ISource;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\Share\Events\ShareReviewAccessCheckEvent;
use OCP\Constants;
use OCP\DB\Exception;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IL10N;
use OCP\Share\IShare;
use Psr\Log\LoggerInterface;

class ShareReviewSource implements ISource {

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<array{id: int, object: string, initiator: string, type: int, recipient: string, permissions: int, password: bool, time: string, action: string}>
*/
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->buildShare($share)->toArray(),
$rawShares,
);
}

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

$event = new ShareReviewAccessCheckEvent('Deck', $shareId);

Check failure on line 63 in lib/ShareReview/ShareReviewSource.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedClass

lib/ShareReview/ShareReviewSource.php:63:16: UndefinedClass: Class, interface or enum named OCP\Share\Events\ShareReviewAccessCheckEvent does not exist (see https://psalm.dev/019)
$this->eventDispatcher->dispatchTyped($event);

if (!$event->isHandled() || !$event->isGranted()) {

Check failure on line 66 in lib/ShareReview/ShareReviewSource.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedMethod

lib/ShareReview/ShareReviewSource.php:66:40: UndefinedMethod: Method OCP\EventDispatcher\Event::isGranted does not exist (see https://psalm.dev/022)

Check failure on line 66 in lib/ShareReview/ShareReviewSource.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedMethod

lib/ShareReview/ShareReviewSource.php:66:16: UndefinedMethod: Method OCP\EventDispatcher\Event::isHandled does not exist (see https://psalm.dev/022)
return false;
}

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

/** @param array<string, mixed> $share */
private function buildShare(array $share): ShareReviewShare {
return new ShareReviewShare(
id: (int)$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('OCA\ShareReview\Sources\ISource')) {
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