Skip to content
Open
3 changes: 3 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

use Exception;
use OCA\Analytics\Datasource\DatasourceEvent;
use OCA\ShareReview\Sources\SourceEvent;
use OCA\Tables\Capabilities;
use OCA\Tables\Event\RowDeletedEvent;
use OCA\Tables\Event\TableDeletedEvent;
Expand All @@ -31,6 +32,7 @@
use OCA\Tables\Search\SearchTablesProvider;
use OCA\Tables\Service\Support\AuditLogServiceInterface;
use OCA\Tables\Service\Support\DefaultAuditLogService;
use OCA\Tables\ShareReview\ShareReviewListener;
use OCA\Tables\UserMigration\TablesMigrator;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
Expand Down Expand Up @@ -79,6 +81,7 @@ public function register(IRegistrationContext $context): void {

$context->registerEventListener(BeforeUserDeletedEvent::class, UserDeletedListener::class);
$context->registerEventListener(DatasourceEvent::class, AnalyticsDatasourceListener::class);
$context->registerEventListener(SourceEvent::class, ShareReviewListener::class);
$context->registerEventListener(RenderReferenceEvent::class, TablesReferenceListener::class);
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
$context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalListener::class);
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\Tables\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);
}
}
235 changes: 235 additions & 0 deletions lib/ShareReview/ShareReviewSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
<?php

declare(strict_types=1);

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

namespace OCA\Tables\ShareReview;

use OCA\ShareReview\Sources\ISource;
use OCP\Constants;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\Share\IShare;
use Psr\Log\LoggerInterface;

class ShareReviewSource implements ISource {

private const SHARE_TABLE = 'tables_shares';
private const TABLES_TABLE = 'tables_tables';
private const VIEWS_TABLE = 'tables_views';
private const CONTEXTS_TABLE = 'tables_contexts_context';
private const CONTEXTS_NAVIGATION_TABLE = 'tables_contexts_navigation';

private const NODE_TYPE_TABLE = 'table';
private const NODE_TYPE_VIEW = 'view';
private const NODE_TYPE_CONTEXT = 'context';

private const RECEIVER_TYPE_LINK = 'link';

private const BATCH_SIZE = 997;

public function __construct(
private IDBConnection $db,
private LoggerInterface $logger,
) {
}

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

/**
* @return list<array{id: int, app: string, object: string, initiator: string, type: int, recipient: string, permissions: int, password: bool, time: string, action: string}>
*/
public function getShares(): array {
$rawShares = $this->fetchAllShares();

$idsByType = $this->groupIdsByNodeType($rawShares);
$tableNames = $this->fetchNames(self::TABLES_TABLE, 'title', $idsByType[self::NODE_TYPE_TABLE]);
$viewNames = $this->fetchNames(self::VIEWS_TABLE, 'title', $idsByType[self::NODE_TYPE_VIEW]);
$contextNames = $this->fetchNames(self::CONTEXTS_TABLE, 'name', $idsByType[self::NODE_TYPE_CONTEXT]);

$appName = $this->getName();
$formatted = [];
foreach ($rawShares as $share) {
$formatted[] = [
'id' => (int)$share['id'],
'app' => $appName,
'object' => $this->resolveObjectName($share, $tableNames, $viewNames, $contextNames),
'initiator' => (string)$share['sender'],
'type' => $this->mapReceiverType((string)$share['receiver_type']),
'recipient' => $share['receiver_type'] === self::RECEIVER_TYPE_LINK
? (string)$share['token']
: (string)$share['receiver'],
'permissions' => $this->computePermissions($share),
'password' => $share['password'] !== null,
'time' => (string)($share['created_at'] ?? '1970-01-01 01:00:00'),
'action' => '',
];
}
return $formatted;
}

public function deleteShare(string $shareId): bool {
$this->logger->info('Tables ShareReview: deleting share {id}', ['id' => $shareId]);
try {
$qb = $this->db->getQueryBuilder();
$qb->delete(self::SHARE_TABLE)
->where($qb->expr()->eq('id', $qb->createNamedParameter((int)$shareId, IQueryBuilder::PARAM_INT)));
$deleted = $qb->executeStatement() > 0;
} catch (Exception $e) {
$this->logger->error('Tables ShareReview: failed to delete share {id}: {message}', ['id' => $shareId, 'message' => $e->getMessage()]);
return false;
}

if ($deleted) {
$this->deleteContextNavigation((int)$shareId);
}

return $deleted;
}

/** @return list<array<string, mixed>> */
private function fetchAllShares(): array {
try {
$qb = $this->db->getQueryBuilder();
$qb->select(
'id', 'sender', 'receiver', 'receiver_type', 'node_id', 'node_type',
'token', 'password',
'permission_read', 'permission_create', 'permission_update',
'permission_delete', 'permission_manage',
'created_at'
)->from(self::SHARE_TABLE)
->orderBy('id', 'ASC');
$result = $qb->executeQuery();
$rows = $result->fetchAll();
$result->closeCursor();
return $rows;
} catch (Exception $e) {
$this->logger->error('Tables ShareReview: failed to fetch shares: {message}', ['message' => $e->getMessage()]);
return [];
}
}

/**
* Group distinct node IDs by node type in a single pass over the share list.
*
* @param list<array<string, mixed>> $shares
* @return array{table: list<int>, view: list<int>, context: list<int>}
*/
private function groupIdsByNodeType(array $shares): array {
$tableIds = [];
$viewIds = [];
$contextIds = [];
foreach ($shares as $share) {
$nodeId = (int)$share['node_id'];
$type = (string)$share['node_type'];
if ($type === self::NODE_TYPE_TABLE) {
$tableIds[$nodeId] = true;
} elseif ($type === self::NODE_TYPE_VIEW) {
$viewIds[$nodeId] = true;
} elseif ($type === self::NODE_TYPE_CONTEXT) {
$contextIds[$nodeId] = true;
}
}
return [
self::NODE_TYPE_TABLE => array_keys($tableIds),
self::NODE_TYPE_VIEW => array_keys($viewIds),
self::NODE_TYPE_CONTEXT => array_keys($contextIds),
];
}

/**
* Batch-fetch node IDs β†’ names, chunked to stay within database IN-clause limits.
*
* @param list<int> $ids
* @return array<int, string>
*/
private function fetchNames(string $table, string $nameColumn, array $ids): array {
if ($ids === []) {
return [];
}

$map = [];
foreach (array_chunk($ids, self::BATCH_SIZE) as $chunk) {
try {
$qb = $this->db->getQueryBuilder();
$qb->select('id', $nameColumn)
->from($table)
->where($qb->expr()->in('id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)));
$result = $qb->executeQuery();
$rows = $result->fetchAll();
$result->closeCursor();
foreach ($rows as $row) {
$map[(int)$row['id']] = (string)$row[$nameColumn];
}
} catch (Exception $e) {
$this->logger->error('Tables ShareReview: failed to fetch names from {table}: {message}', ['table' => $table, 'message' => $e->getMessage()]);
}
}
return $map;
}

private function deleteContextNavigation(int $shareId): void {
try {
$qb = $this->db->getQueryBuilder();
$qb->delete(self::CONTEXTS_NAVIGATION_TABLE)
->where($qb->expr()->eq('share_id', $qb->createNamedParameter($shareId, IQueryBuilder::PARAM_INT)));
$qb->executeStatement();
} catch (Exception $e) {
$this->logger->error('Tables ShareReview: failed to clean up context navigation for share {id}: {message}', ['id' => $shareId, 'message' => $e->getMessage()]);
}
}

/**
* @param array<string, mixed> $share
* @param array<int, string> $tableNames
* @param array<int, string> $viewNames
* @param array<int, string> $contextNames
*/
private function resolveObjectName(array $share, array $tableNames, array $viewNames, array $contextNames): string {
$nodeId = (int)$share['node_id'];
return match($share['node_type']) {
self::NODE_TYPE_TABLE => ($tableNames[$nodeId] ?? "Table $nodeId") . ' (Table)',
self::NODE_TYPE_VIEW => ($viewNames[$nodeId] ?? "View $nodeId") . ' (View)',
self::NODE_TYPE_CONTEXT => ($contextNames[$nodeId] ?? "Context $nodeId") . ' (Context)',
default => "Unknown $nodeId",
};
}

private function mapReceiverType(string $receiverType): int {
return match($receiverType) {
'user' => IShare::TYPE_USER,
'group' => IShare::TYPE_GROUP,
'link' => IShare::TYPE_LINK,
'circle' => IShare::TYPE_CIRCLE,
default => IShare::TYPE_USER,
};
}

/** @param array<string, mixed> $share */
private function computePermissions(array $share): int {
$permissions = 0;
if ($share['permission_read']) {
$permissions |= Constants::PERMISSION_READ;
}
if ($share['permission_update']) {
$permissions |= Constants::PERMISSION_UPDATE;
}
if ($share['permission_create']) {
$permissions |= Constants::PERMISSION_CREATE;
}
if ($share['permission_delete']) {
$permissions |= Constants::PERMISSION_DELETE;
}
if ($share['permission_manage']) {
$permissions |= Constants::PERMISSION_SHARE;
}
return $permissions > 0 ? $permissions : Constants::PERMISSION_READ;
}
}
12 changes: 12 additions & 0 deletions tests/stub.phpstub
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ namespace OCA\Analytics\Datasource {
}
}

namespace OCA\ShareReview\Sources {
class SourceEvent extends \OCP\EventDispatcher\Event {
abstract public function registerSource(string $source): void {}
}

interface ISource {
public function getName(): string;
public function getShares(): array;
public function deleteShare(string $shareId): bool;
}
}

namespace OCA\Circles\Model {
class Circle {
abstract public function getSingleId(): string {}
Expand Down
Loading
Loading