Skip to content

Commit 009f0bb

Browse files
committed
fix(core): ensure unique vcategory
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
1 parent 2672301 commit 009f0bb

File tree

8 files changed

+338
-2
lines changed

8 files changed

+338
-2
lines changed

core/Application.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,12 @@ public function __construct() {
231231
'systag_by_objectid',
232232
['objectid']
233233
);
234+
235+
$event->addMissingUniqueIndex(
236+
'vcategory',
237+
'unique_category_per_user',
238+
['uid', 'type', 'category']
239+
);
234240
});
235241

236242
$eventDispatcher->addListener(AddMissingPrimaryKeyEvent::class, function (AddMissingPrimaryKeyEvent $event) {
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OC\Core\Listener;
11+
12+
use OCP\DB\Events\AddMissingIndicesEvent;
13+
use OCP\EventDispatcher\Event;
14+
use OCP\EventDispatcher\IEventListener;
15+
16+
/**
17+
* @template-implements IEventListener<AddMissingIndicesEvent>
18+
*/
19+
class AddMissingIndicesListener implements IEventListener {
20+
21+
public function handle(Event $event): void {
22+
if (!($event instanceof AddMissingIndicesEvent)) {
23+
return;
24+
}
25+
26+
$event->addMissingIndex(
27+
'share',
28+
'share_with_index',
29+
['share_with']
30+
);
31+
$event->addMissingIndex(
32+
'share',
33+
'parent_index',
34+
['parent']
35+
);
36+
$event->addMissingIndex(
37+
'share',
38+
'owner_index',
39+
['uid_owner']
40+
);
41+
$event->addMissingIndex(
42+
'share',
43+
'initiator_index',
44+
['uid_initiator']
45+
);
46+
47+
$event->addMissingIndex(
48+
'filecache',
49+
'fs_mtime',
50+
['mtime']
51+
);
52+
$event->addMissingIndex(
53+
'filecache',
54+
'fs_size',
55+
['size']
56+
);
57+
$event->addMissingIndex(
58+
'filecache',
59+
'fs_storage_path_prefix',
60+
['storage', 'path'],
61+
['lengths' => [null, 64]]
62+
);
63+
$event->addMissingIndex(
64+
'filecache',
65+
'fs_parent',
66+
['parent']
67+
);
68+
$event->addMissingIndex(
69+
'filecache',
70+
'fs_name_hash',
71+
['name']
72+
);
73+
74+
$event->addMissingIndex(
75+
'twofactor_providers',
76+
'twofactor_providers_uid',
77+
['uid']
78+
);
79+
80+
$event->addMissingUniqueIndex(
81+
'login_flow_v2',
82+
'poll_token',
83+
['poll_token'],
84+
[],
85+
true
86+
);
87+
$event->addMissingUniqueIndex(
88+
'login_flow_v2',
89+
'login_token',
90+
['login_token'],
91+
[],
92+
true
93+
);
94+
$event->addMissingIndex(
95+
'login_flow_v2',
96+
'timestamp',
97+
['timestamp'],
98+
[],
99+
true
100+
);
101+
102+
$event->addMissingIndex(
103+
'whats_new',
104+
'version',
105+
['version'],
106+
[],
107+
true
108+
);
109+
110+
$event->addMissingIndex(
111+
'cards',
112+
'cards_abiduri',
113+
['addressbookid', 'uri'],
114+
[],
115+
true
116+
);
117+
118+
$event->replaceIndex(
119+
'cards_properties',
120+
['cards_prop_abid'],
121+
'cards_prop_abid_name_value',
122+
['addressbookid', 'name', 'value'],
123+
false,
124+
);
125+
126+
$event->addMissingIndex(
127+
'calendarobjects_props',
128+
'calendarobject_calid_index',
129+
['calendarid', 'calendartype']
130+
);
131+
132+
$event->addMissingIndex(
133+
'schedulingobjects',
134+
'schedulobj_principuri_index',
135+
['principaluri']
136+
);
137+
138+
$event->addMissingIndex(
139+
'schedulingobjects',
140+
'schedulobj_lastmodified_idx',
141+
['lastmodified']
142+
);
143+
144+
$event->addMissingIndex(
145+
'properties',
146+
'properties_path_index',
147+
['userid', 'propertypath']
148+
);
149+
$event->addMissingIndex(
150+
'properties',
151+
'properties_pathonly_index',
152+
['propertypath']
153+
);
154+
$event->addMissingIndex(
155+
'properties',
156+
'properties_name_path_user',
157+
['propertyname', 'propertypath', 'userid']
158+
);
159+
160+
161+
$event->addMissingIndex(
162+
'jobs',
163+
'job_lastcheck_reserved',
164+
['last_checked', 'reserved_at']
165+
);
166+
167+
$event->addMissingIndex(
168+
'direct_edit',
169+
'direct_edit_timestamp',
170+
['timestamp']
171+
);
172+
173+
$event->addMissingIndex(
174+
'preferences',
175+
'prefs_uid_lazy_i',
176+
['userid', 'lazy']
177+
);
178+
$event->addMissingIndex(
179+
'preferences',
180+
'prefs_app_key_ind_fl_i',
181+
['appid', 'configkey', 'indexed', 'flags']
182+
);
183+
184+
$event->addMissingIndex(
185+
'mounts',
186+
'mounts_class_index',
187+
['mount_provider_class']
188+
);
189+
$event->addMissingIndex(
190+
'mounts',
191+
'mounts_user_root_path_index',
192+
['user_id', 'root_id', 'mount_point'],
193+
['lengths' => [null, null, 128]]
194+
);
195+
196+
$event->addMissingIndex(
197+
'systemtag_object_mapping',
198+
'systag_by_tagid',
199+
['systemtagid', 'objecttype']
200+
);
201+
202+
$event->addMissingIndex(
203+
'systemtag_object_mapping',
204+
'systag_by_objectid',
205+
['objectid']
206+
);
207+
208+
$event->addMissingIndex(
209+
'systemtag_object_mapping',
210+
'systag_objecttype',
211+
['objecttype']
212+
);
213+
214+
$event->addMissingUniqueIndex(
215+
'vcategory',
216+
'unique_category_per_user',
217+
['uid', 'type', 'category']
218+
);
219+
}
220+
}

core/Migrations/Version13000Date20170718121200.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,7 @@ public function changeSchema(IOutput $output, \Closure $schemaClosure, array $op
658658
$table->addIndex(['uid'], 'uid_index');
659659
$table->addIndex(['type'], 'type_index');
660660
$table->addIndex(['category'], 'category_index');
661+
$table->addUniqueIndex(['uid', 'type', 'category'], 'unique_category_per_user');
661662
}
662663

663664
if (!$schema->hasTable('vcategory_to_object')) {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OC\Core\Migrations;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\IDBConnection;
15+
use OCP\Migration\IOutput;
16+
use OCP\Migration\SimpleMigrationStep;
17+
use Override;
18+
19+
/**
20+
* Make sure vcategory entries are unique per user and type
21+
* This migration will clean up existing duplicates
22+
* and add a unique constraint to prevent future duplicates.
23+
*/
24+
class Version32000Date20250731062008 extends SimpleMigrationStep {
25+
public function __construct(
26+
private IDBConnection $connection,
27+
) {
28+
}
29+
30+
/**
31+
* @param IOutput $output
32+
* @param Closure(): ISchemaWrapper $schemaClosure
33+
* @param array $options
34+
*/
35+
#[Override]
36+
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
37+
// Clean up duplicate categories before adding unique constraint
38+
$this->cleanupDuplicateCategories($output);
39+
}
40+
41+
/**
42+
* Clean up duplicate categories
43+
*/
44+
private function cleanupDuplicateCategories(IOutput $output) {
45+
$output->info('Starting cleanup of duplicate vcategory records...');
46+
47+
// Find all categories, ordered to identify duplicates
48+
$qb = $this->connection->getQueryBuilder();
49+
$qb->select('id', 'uid', 'type', 'category')
50+
->from('vcategory')
51+
->orderBy('uid')
52+
->addOrderBy('type')
53+
->addOrderBy('category')
54+
->addOrderBy('id');
55+
56+
$result = $qb->executeQuery();
57+
58+
$seen = [];
59+
$duplicateCount = 0;
60+
61+
while ($category = $result->fetch()) {
62+
$key = $category['uid'] . '|' . $category['type'] . '|' . $category['category'];
63+
$categoryId = (int)$category['id'];
64+
65+
if (!isset($seen[$key])) {
66+
// First occurrence - keep this one
67+
$seen[$key] = $categoryId;
68+
continue;
69+
}
70+
71+
// Duplicate found
72+
$keepId = $seen[$key];
73+
$duplicateCount++;
74+
75+
$output->info("Found duplicate: keeping ID $keepId, removing ID $categoryId");
76+
77+
// Update object references
78+
$updateQb = $this->connection->getQueryBuilder();
79+
$updateQb->update('vcategory_to_object')
80+
->set('categoryid', $updateQb->createNamedParameter($keepId))
81+
->where($updateQb->expr()->eq('categoryid', $updateQb->createNamedParameter($categoryId)));
82+
83+
$affectedRows = $updateQb->executeStatement();
84+
if ($affectedRows > 0) {
85+
$output->info(" - Updated $affectedRows object references from category $categoryId to $keepId");
86+
}
87+
88+
// Remove duplicate category record
89+
$deleteQb = $this->connection->getQueryBuilder();
90+
$deleteQb->delete('vcategory')
91+
->where($deleteQb->expr()->eq('id', $deleteQb->createNamedParameter($categoryId)));
92+
93+
$deleteQb->executeStatement();
94+
$output->info(" - Deleted duplicate category record ID $categoryId");
95+
96+
}
97+
98+
$result->closeCursor();
99+
100+
if ($duplicateCount === 0) {
101+
$output->info('No duplicate categories found');
102+
} else {
103+
$output->info("Duplicate cleanup completed - processed $duplicateCount duplicates");
104+
}
105+
}
106+
}

lib/composer/composer/autoload_classmap.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,6 +1304,7 @@
13041304
'OC\\Core\\Events\\PasswordResetEvent' => $baseDir . '/core/Events/PasswordResetEvent.php',
13051305
'OC\\Core\\Exception\\LoginFlowV2NotFoundException' => $baseDir . '/core/Exception/LoginFlowV2NotFoundException.php',
13061306
'OC\\Core\\Exception\\ResetPasswordException' => $baseDir . '/core/Exception/ResetPasswordException.php',
1307+
'OC\\Core\\Listener\\AddMissingIndicesListener' => $baseDir . '/core/Listener/AddMissingIndicesListener.php',
13071308
'OC\\Core\\Listener\\BeforeMessageLoggedEventListener' => $baseDir . '/core/Listener/BeforeMessageLoggedEventListener.php',
13081309
'OC\\Core\\Listener\\BeforeTemplateRenderedListener' => $baseDir . '/core/Listener/BeforeTemplateRenderedListener.php',
13091310
'OC\\Core\\Middleware\\TwoFactorMiddleware' => $baseDir . '/core/Middleware/TwoFactorMiddleware.php',
@@ -1382,6 +1383,7 @@
13821383
'OC\\Core\\Migrations\\Version30000Date20240814180800' => $baseDir . '/core/Migrations/Version30000Date20240814180800.php',
13831384
'OC\\Core\\Migrations\\Version30000Date20240815080800' => $baseDir . '/core/Migrations/Version30000Date20240815080800.php',
13841385
'OC\\Core\\Migrations\\Version30000Date20240906095113' => $baseDir . '/core/Migrations/Version30000Date20240906095113.php',
1386+
'OC\\Core\\Migrations\\Version32000Date20250731062008' => $baseDir . '/core/Migrations/Version32000Date20250731062008.php',
13851387
'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php',
13861388
'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php',
13871389
'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php',

lib/composer/composer/autoload_static.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,6 +1337,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
13371337
'OC\\Core\\Events\\PasswordResetEvent' => __DIR__ . '/../../..' . '/core/Events/PasswordResetEvent.php',
13381338
'OC\\Core\\Exception\\LoginFlowV2NotFoundException' => __DIR__ . '/../../..' . '/core/Exception/LoginFlowV2NotFoundException.php',
13391339
'OC\\Core\\Exception\\ResetPasswordException' => __DIR__ . '/../../..' . '/core/Exception/ResetPasswordException.php',
1340+
'OC\\Core\\Listener\\AddMissingIndicesListener' => __DIR__ . '/../../..' . '/core/Listener/AddMissingIndicesListener.php',
13401341
'OC\\Core\\Listener\\BeforeMessageLoggedEventListener' => __DIR__ . '/../../..' . '/core/Listener/BeforeMessageLoggedEventListener.php',
13411342
'OC\\Core\\Listener\\BeforeTemplateRenderedListener' => __DIR__ . '/../../..' . '/core/Listener/BeforeTemplateRenderedListener.php',
13421343
'OC\\Core\\Middleware\\TwoFactorMiddleware' => __DIR__ . '/../../..' . '/core/Middleware/TwoFactorMiddleware.php',
@@ -1415,6 +1416,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
14151416
'OC\\Core\\Migrations\\Version30000Date20240814180800' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240814180800.php',
14161417
'OC\\Core\\Migrations\\Version30000Date20240815080800' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240815080800.php',
14171418
'OC\\Core\\Migrations\\Version30000Date20240906095113' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240906095113.php',
1419+
'OC\\Core\\Migrations\\Version32000Date20250731062008' => __DIR__ . '/../../..' . '/core/Migrations/Version32000Date20250731062008.php',
14181420
'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php',
14191421
'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php',
14201422
'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php',

lib/private/Tags.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,6 @@ public function add(string $name) {
274274
return false;
275275
}
276276
if ($this->userHasTag($name, $this->user)) {
277-
// TODO use unique db properties instead of an additional check
278277
$this->logger->debug(__METHOD__ . ' Tag with name already exists', ['app' => 'core']);
279278
return false;
280279
}

version.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
// between betas, final and RCs. This is _not_ the public version number. Reset minor/patch level
1010
// when updating major/minor version number.
1111

12-
$OC_Version = [30, 0, 13, 1];
12+
$OC_Version = [30, 0, 13, 2];
1313

1414
// The human-readable string
1515
$OC_VersionString = '30.0.13';

0 commit comments

Comments
 (0)