Skip to content
Open
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
3 changes: 3 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@
use OCA\Forms\Listener\UserDeletedListener;
use OCA\Forms\Middleware\ThrottleFormAccessMiddleware;
use OCA\Forms\Search\SearchProvider;
use OCA\Forms\ShareReview\ShareReviewListener;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Comments\CommentsEntityEvent;
use OCP\Share\ShareReview\RegisterShareReviewSourceEvent;
use OCP\User\Events\UserDeletedEvent;

class Application extends App implements IBootstrap {
Expand All @@ -47,6 +49,7 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
$context->registerEventListener(DatasourceEvent::class, AnalyticsDatasourceListener::class);
$context->registerEventListener(CommentsEntityEvent::class, CommentsEntityListener::class);
$context->registerEventListener(RegisterShareReviewSourceEvent::class, ShareReviewListener::class);
$context->registerMiddleware(ThrottleFormAccessMiddleware::class);
$context->registerSearchProvider(SearchProvider::class);
$context->registerUserMigrator(FormsMigrator::class);
Expand Down
27 changes: 27 additions & 0 deletions lib/Db/ShareMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\Share\IShare;
Expand Down Expand Up @@ -87,6 +88,32 @@ public function findPublicShareByHash(string $hash): Share {
return $this->findEntity($qb);
}

/**
* Fetch all share rows with their form title, owner and timestamps for ShareReview.
*
* @return list<array<string, mixed>>
* @throws Exception
*/
public function findAllForShareReview(): array {
$qb = $this->db->getQueryBuilder();

$qb->select('s.id', 's.form_id', 's.share_type', 's.share_with', 's.permissions_json')
->selectAlias('f.title', 'form_title')
->selectAlias('f.owner_id', 'form_owner')
->selectAlias('f.created', 'form_created')
->selectAlias('f.last_updated', 'form_last_updated')
->selectAlias('f.expires', 'form_expires')
->from($this->getTableName(), 's')
->leftJoin('s', 'forms_v2_forms', 'f', $qb->expr()->eq('s.form_id', 'f.id'))
->orderBy('s.id', 'ASC');

$result = $qb->executeQuery();
/** @var list<array<string, mixed>> $rows */
$rows = $result->fetchAll();
$result->closeCursor();
return $rows;
}

/**
* Delete all Shares of a form.
* @param int $formId
Expand Down
24 changes: 24 additions & 0 deletions lib/ShareReview/ShareReviewListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

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

namespace OCA\Forms\ShareReview;

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

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

declare(strict_types=1);

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

namespace OCA\Forms\ShareReview;

use OCA\Forms\Constants;
use OCA\Forms\Db\FormMapper;
use OCA\Forms\Db\ShareMapper;
use OCA\Forms\Service\UploadedFilesShareService;
use OCP\AppFramework\Db\IMapperException;
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 OCP\Share\ShareReview\ShareReviewPermission;
use Psr\Log\LoggerInterface;

class ShareReviewSource implements IShareReviewSource {

public const PERMISSION_READ = 'forms:read';
public const PERMISSION_EDIT = 'forms:edit';
public const PERMISSION_SUBMIT = 'forms:submit';
public const PERMISSION_RESULTS = 'forms:results';
public const PERMISSION_RESULTS_DELETE = 'forms:results_delete';
public const PERMISSION_EMBED = 'forms:embed';

/** @var array<string, ShareReviewPermission>|null */
private ?array $permissionCatalog = null;

public function __construct(
private readonly ShareMapper $shareMapper,
private readonly FormMapper $formMapper,
private readonly UploadedFilesShareService $uploadedFilesShareService,
private readonly IEventDispatcher $eventDispatcher,
private readonly LoggerInterface $logger,
private readonly IL10N $l10n,
) {
}

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

/**
* @return list<ShareReviewEntry>
*/
public function getShares(): array {
try {
$rawShares = $this->shareMapper->findAllForShareReview();
} catch (Exception $e) {
$this->logger->error('Forms 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 {
// Digits only, so the ID the access-check event carries is exactly the row being deleted
// (is_numeric would also accept '1e3' or '7.5', which (int) casts to a different value)
if (!ctype_digit($shareId)) {
return false;
}
$numericShareId = (int)$shareId;

$event = new ShareReviewAccessCheckEvent('Forms', (string)$numericShareId);
$this->eventDispatcher->dispatchTyped($event);

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

try {
$share = $this->shareMapper->findById($numericShareId);
$form = $this->formMapper->findById($share->getFormId());
} catch (IMapperException) {
return false;
}

try {
// Revoke any linked Files share before deleting the Forms share
if (in_array(Constants::PERMISSION_RESULTS, $share->getPermissions(), true)) {
$this->uploadedFilesShareService->removeForCollaborator($form, $share);
}
$this->shareMapper->delete($share);
// Bump the form's last_updated timestamp, matching the regular deletion flow
$this->formMapper->update($form);
return true;
} catch (\Exception $e) {
$this->logger->error('Forms ShareReview: failed to delete share {id}: {message}', ['id' => $shareId, 'message' => $e->getMessage()]);
return false;
}
}

/** @param array<string, mixed> $share */
private function buildEntry(array $share): ShareReviewEntry {
// last_updated is bumped on every share change of the form, created is the lower bound
$time = (int)($share['form_last_updated'] ?? 0) ?: (int)($share['form_created'] ?? 0);
$expires = (int)($share['form_expires'] ?? 0);

return new ShareReviewEntry(
id: (string)$share['id'],
object: $this->resolveObjectName($share),
initiator: (string)$share['form_owner'],
type: $this->mapShareType((int)$share['share_type']),
recipient: (string)$share['share_with'],
lastModifiedTimestamp: $time,
permissions: $this->buildPermissions($this->decodePermissions($share)),
expirationTimestamp: $expires > 0 ? $expires : null,
);
}

/** @param array<string, mixed> $share */
private function resolveObjectName(array $share): string {
$title = (string)($share['form_title'] ?? '');
$formId = (int)($share['form_id'] ?? $share['id']);
$label = $title !== '' ? $title : $this->l10n->t('Form %d', [$formId]);
return $this->l10n->t('%s (Form)', [$label]);
}

private function mapShareType(int $type): int {
if (in_array($type, Constants::SHARE_TYPES_USED, true)) {
return $type;
}
$this->logger->warning('Forms ShareReview: unknown share type {type}, defaulting to user share', ['type' => $type]);
return IShare::TYPE_USER;
}

/**
* @param array<string, mixed> $share
* @return list<string>
*/
private function decodePermissions(array $share): array {
// Same fallback to submit permission as OCA\Forms\Db\Share::getPermissions()
return json_decode((string)($share['permissions_json'] ?? '') ?: 'null', true) ?? [Constants::PERMISSION_SUBMIT];
}

/**
* @param list<string> $formPermissions
* @return list<ShareReviewPermission>
*/
private function buildPermissions(array $formPermissions): array {
$catalog = $this->permissionCatalog();
// Any share grants seeing the form itself
$permissions = [$catalog[self::PERMISSION_READ]];
foreach ([
Constants::PERMISSION_EDIT => self::PERMISSION_EDIT,
Constants::PERMISSION_SUBMIT => self::PERMISSION_SUBMIT,
Constants::PERMISSION_RESULTS => self::PERMISSION_RESULTS,
Constants::PERMISSION_RESULTS_DELETE => self::PERMISSION_RESULTS_DELETE,
Constants::PERMISSION_EMBED => self::PERMISSION_EMBED,
] as $formPermission => $permissionId) {
if (in_array($formPermission, $formPermissions, true)) {
$permissions[] = $catalog[$permissionId];
}
}
return $permissions;
}

/**
* The permission objects are immutable and identical for every share row,
* so they are built once per request instead of once per row.
*
* All permission IDs are namespaced to this app, and labels and hints are
* translated from this app's own catalog — the app owning a permission
* also owns its wording in every language.
*
* @return array<string, ShareReviewPermission>
*/
private function permissionCatalog(): array {
return $this->permissionCatalog ??= [
self::PERMISSION_READ => new ShareReviewPermission(self::PERMISSION_READ, $this->l10n->t('Read'), priority: 80),
self::PERMISSION_EDIT => new ShareReviewPermission(self::PERMISSION_EDIT, $this->l10n->t('Edit form'), priority: 70),
self::PERMISSION_SUBMIT => new ShareReviewPermission(self::PERMISSION_SUBMIT, $this->l10n->t('Submit'), $this->l10n->t('Fill in the form'), 60),
self::PERMISSION_RESULTS => new ShareReviewPermission(self::PERMISSION_RESULTS, $this->l10n->t('View results'), priority: 50),
self::PERMISSION_RESULTS_DELETE => new ShareReviewPermission(self::PERMISSION_RESULTS_DELETE, $this->l10n->t('Delete results'), priority: 45),
self::PERMISSION_EMBED => new ShareReviewPermission(self::PERMISSION_EMBED, $this->l10n->t('Embed'), $this->l10n->t('Embed the form in external websites'), 35),
];
}
}
1 change: 1 addition & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@
<stubs>
<file name="tests/stubs/oc_hooks_emitter.php" />
<file name="tests/stubs/oca-analytics-idatasource.php" />
<file name="tests/stubs/ocp-share-sharereview.php" />
</stubs>
</psalm>
35 changes: 35 additions & 0 deletions tests/Unit/AppInfo/ApplicationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

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

namespace OCA\Forms\Tests\Unit\AppInfo;

use OCA\Forms\AppInfo\Application;
use OCA\Forms\ShareReview\ShareReviewListener;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Share\ShareReview\RegisterShareReviewSourceEvent;
use PHPUnit\Framework\TestCase;

class ApplicationTest extends TestCase {
public function testRegisterRegistersEventListeners(): void {
$registeredListeners = [];

$context = $this->createMock(IRegistrationContext::class);
$context->method('registerEventListener')
->willReturnCallback(function (string $event, string $listener) use (&$registeredListeners): void {
$registeredListeners[$event] = $listener;
});

(new Application())->register($context);

$this->assertSame(
ShareReviewListener::class,
$registeredListeners[RegisterShareReviewSourceEvent::class] ?? null,
);
}
}
Loading
Loading