Skip to content

Commit 1e03eff

Browse files
committed
feat(sharing): add occ sharing:fix-owncloud-group-shares command
Repairs existing USERGROUP subshares created by ownCloud-migrated group shares that were renamed before the move() fix, leaving them with accepted=0 (STATUS_PENDING) and non-zero permissions. Declined shares (permissions=0) are deliberately excluded. AI-Assisted-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Anna Larch <anna@nextcloud.com>
1 parent 2ace6a3 commit 1e03eff

3 files changed

Lines changed: 91 additions & 0 deletions

File tree

apps/files_sharing/appinfo/info.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Turning the feature off removes shared files and folders on the server for all s
5050
<command>OCA\Files_Sharing\Command\ExiprationNotification</command>
5151
<command>OCA\Files_Sharing\Command\DeleteOrphanShares</command>
5252
<command>OCA\Files_Sharing\Command\FixShareOwners</command>
53+
<command>OCA\Files_Sharing\Command\FixOwncloudGroupShares</command>
5354
<command>OCA\Files_Sharing\Command\ListShares</command>
5455
</commands>
5556

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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\Files_Sharing\Command;
11+
12+
use OC\Core\Command\Base;
13+
use OCP\DB\QueryBuilder\IQueryBuilder;
14+
use OCP\IDBConnection;
15+
use OCP\Share\IShare;
16+
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Input\InputOption;
18+
use Symfony\Component\Console\Output\OutputInterface;
19+
20+
/**
21+
* Fixes USERGROUP subshares that were created without `accepted = STATUS_ACCEPTED`
22+
* by the rename bug affecting ownCloud-migrated group shares.
23+
*
24+
* When an ownCloud-migrated group share (which has no per-user USERGROUP subshare)
25+
* is renamed for the first time, a new USERGROUP row is inserted without an
26+
* `accepted` value. The column defaults to 0 (STATUS_PENDING), causing
27+
* MountProvider to skip the share — the file disappears for the recipient.
28+
*
29+
* A USERGROUP subshare with permissions = 0 was explicitly declined by the user
30+
* and must not be touched.
31+
*/
32+
class FixOwncloudGroupShares extends Base {
33+
public function __construct(
34+
private IDBConnection $connection,
35+
) {
36+
parent::__construct();
37+
}
38+
39+
protected function configure(): void {
40+
$this
41+
->setName('sharing:fix-owncloud-group-shares')
42+
->setDescription('Fix group share subshares left with accepted = STATUS_PENDING after renaming on an ownCloud-migrated instance')
43+
->addOption(
44+
'dry-run',
45+
null,
46+
InputOption::VALUE_NONE,
47+
'Show how many shares would be fixed without making any changes',
48+
);
49+
}
50+
51+
public function execute(InputInterface $input, OutputInterface $output): int {
52+
$dryRun = $input->getOption('dry-run');
53+
54+
$qb = $this->connection->getQueryBuilder();
55+
$count = (int)$qb->select($qb->func()->count('id'))
56+
->from('share')
57+
->where($qb->expr()->eq('share_type', $qb->createNamedParameter(IShare::TYPE_USERGROUP, IQueryBuilder::PARAM_INT)))
58+
->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(IShare::STATUS_PENDING, IQueryBuilder::PARAM_INT)))
59+
->andWhere($qb->expr()->neq('permissions', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)))
60+
->executeQuery()
61+
->fetchOne();
62+
63+
if ($count === 0) {
64+
$output->writeln('No affected group share subshares found.');
65+
return self::SUCCESS;
66+
}
67+
68+
if ($dryRun) {
69+
$output->writeln("Would fix <info>$count</info> group share subshare(s) (dry-run, no changes made).");
70+
return self::SUCCESS;
71+
}
72+
73+
$qb = $this->connection->getQueryBuilder();
74+
$qb->update('share')
75+
->set('accepted', $qb->createNamedParameter(IShare::STATUS_ACCEPTED, IQueryBuilder::PARAM_INT))
76+
->where($qb->expr()->eq('share_type', $qb->createNamedParameter(IShare::TYPE_USERGROUP, IQueryBuilder::PARAM_INT)))
77+
->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(IShare::STATUS_PENDING, IQueryBuilder::PARAM_INT)))
78+
->andWhere($qb->expr()->neq('permissions', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)))
79+
->executeStatement();
80+
81+
$output->writeln("Fixed <info>$count</info> group share subshare(s).");
82+
return self::SUCCESS;
83+
}
84+
}

tests/lib/Share20/DefaultShareProviderTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2269,12 +2269,18 @@ public function testMoveGroupShare(): void {
22692269

22702270
$share = $this->provider->getShareById($id, 'user0');
22712271
$this->assertSame('/newTarget', $share->getTarget());
2272+
// The USERGROUP subshare created on first move must be STATUS_ACCEPTED so
2273+
// MountProvider does not skip it (default DB value is STATUS_PENDING=0).
2274+
$this->assertSame(IShare::STATUS_ACCEPTED, $share->getStatus());
22722275

22732276
$share->setTarget('/ultraNewTarget');
22742277
$this->provider->move($share, 'user0');
22752278

22762279
$share = $this->provider->getShareById($id, 'user0');
22772280
$this->assertSame('/ultraNewTarget', $share->getTarget());
2281+
// Second move hits the UPDATE branch (USERGROUP subshare already exists).
2282+
// STATUS_ACCEPTED must be preserved — the UPDATE only touches file_target.
2283+
$this->assertSame(IShare::STATUS_ACCEPTED, $share->getStatus());
22782284
}
22792285

22802286
public static function dataDeleteUser(): array {

0 commit comments

Comments
 (0)