diff --git a/lib/Folder/FolderManager.php b/lib/Folder/FolderManager.php index 9bc3868c6..4c86f3bc5 100644 --- a/lib/Folder/FolderManager.php +++ b/lib/Folder/FolderManager.php @@ -976,7 +976,50 @@ public function setFolderQuota(int $folderId, int $quota): void { ->where($query->expr()->eq('folder_id', $query->createNamedParameter($folderId))); $query->executeStatement(); - $this->eventDispatcher->dispatchTyped(new CriticalActionPerformedEvent('The quota for groupfolder with id %d was set to %d bytes', [$folderId, $quota])); + $this->eventDispatcher->dispatchTyped( + new CriticalActionPerformedEvent( + 'The quota for groupfolder with id %d was set to %d bytes', + [$folderId, $quota] + ) + ); + + // Invalidate the root etag of this group folder directly in the filecache + // using the root_id already stored in group_folders. This forces desktop + // clients to issue a fresh PROPFIND on the next sync cycle and receive the + // updated quota-available-bytes value. + // + // Direct filecache update is used intentionally: + // - propagateChange() requires a mounted storage (i.e. an active user + // session), which is not guaranteed when an admin changes quota. + // - root_id is always present in group_folders after folder creation, + // so this path works regardless of whether any user is logged in. + try { + $rootIdQuery = $this->connection->getQueryBuilder(); + $rootIdQuery->select('root_id') + ->from('group_folders') + ->where($rootIdQuery->expr()->eq( + 'folder_id', + $rootIdQuery->createNamedParameter($folderId, IQueryBuilder::PARAM_INT) + )); + $rootIdResult = $rootIdQuery->executeQuery(); + $raw = $rootIdResult->fetchOne(); + $rootIdResult->closeCursor(); + $rootId = is_numeric($raw) ? (int)$raw : 0; + + if ($rootId > 0) { + $etagQuery = $this->connection->getQueryBuilder(); + $etagQuery->update('filecache') + ->set('etag', $etagQuery->createNamedParameter(uniqid())) + ->where($etagQuery->expr()->eq( + 'fileid', + $etagQuery->createNamedParameter($rootId, IQueryBuilder::PARAM_INT) + )); + $etagQuery->executeStatement(); + } + } catch (\Throwable) { + // Non-fatal: best-effort invalidation. + // If this fails the stale etag will correct itself on the next full sync. + } } /** diff --git a/tests/Folder/FolderManagerTest.php b/tests/Folder/FolderManagerTest.php index eda4f24ee..350ebf024 100644 --- a/tests/Folder/FolderManagerTest.php +++ b/tests/Folder/FolderManagerTest.php @@ -16,6 +16,7 @@ use OCA\GroupFolders\Mount\FolderStorageManager; use OCA\GroupFolders\ResponseDefinitions; use OCP\Constants; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\FileInfo; use OCP\Files\IMimeTypeLoader; @@ -73,11 +74,57 @@ protected function setUp(): void { } private function clean(): void { - $query = Server::get(IDBConnection::class)->getQueryBuilder(); - $query->delete('group_folders')->executeStatement(); + $db = Server::get(IDBConnection::class); + + $db->getQueryBuilder()->delete('group_folders')->executeStatement(); + $db->getQueryBuilder()->delete('group_folders_groups')->executeStatement(); + + // Remove any filecache rows seeded by seedFilecacheForFolder(). + // storage = 0 is used as a sentinel as no real storage is + // ever assigned numeric ID 0, so this delete is always safe. + $q = $db->getQueryBuilder(); + $q->delete('filecache') + ->where($q->expr()->eq('storage', $q->createNamedParameter(0, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } - $query = Server::get(IDBConnection::class)->getQueryBuilder(); - $query->delete('group_folders_groups')->executeStatement(); + /** + * Insert a minimal filecache row so that getFolder()'s INNER JOIN on + * filecache succeeds, then patch group_folders.root_id to point at it. + * storage = 0 as a sentinel value that clean() sweeps unconditionally. + */ + private function seedFilecacheForFolder(int $folderId): void { + $db = Server::get(IDBConnection::class); + $now = time(); + + $insert = $db->getQueryBuilder(); + $insert->insert('filecache') + ->values([ + 'storage' => $insert->createNamedParameter(0, IQueryBuilder::PARAM_INT), + 'path' => $insert->createNamedParameter(''), + 'path_hash' => $insert->createNamedParameter(md5('')), + 'parent' => $insert->createNamedParameter(-1, IQueryBuilder::PARAM_INT), + 'name' => $insert->createNamedParameter(''), + 'mimetype' => $insert->createNamedParameter(0, IQueryBuilder::PARAM_INT), + 'mimepart' => $insert->createNamedParameter(0, IQueryBuilder::PARAM_INT), + 'size' => $insert->createNamedParameter(0, IQueryBuilder::PARAM_INT), + 'mtime' => $insert->createNamedParameter($now, IQueryBuilder::PARAM_INT), + 'storage_mtime' => $insert->createNamedParameter($now, IQueryBuilder::PARAM_INT), + 'etag' => $insert->createNamedParameter(uniqid()), + 'encrypted' => $insert->createNamedParameter(0, IQueryBuilder::PARAM_INT), + 'permissions' => $insert->createNamedParameter(0, IQueryBuilder::PARAM_INT), + ]); + $insert->executeStatement(); + $filecacheId = $insert->getLastInsertId(); + + $update = $db->getQueryBuilder(); + $update->update('group_folders') + ->set('root_id', $update->createNamedParameter($filecacheId, IQueryBuilder::PARAM_INT)) + ->where($update->expr()->eq( + 'folder_id', + $update->createNamedParameter($folderId, IQueryBuilder::PARAM_INT) + )) + ->executeStatement(); } /** @@ -494,6 +541,38 @@ public function testGetFolderPermissionsForUserMerge(): void { $this->assertEquals(0, $permissions); } + public function testSetFolderQuotaInvalidatesEtag(): void { + $this->config->expects($this->any()) + ->method('getSystemValueInt') + ->with('groupfolders.quota.default', FileInfo::SPACE_UNLIMITED) + ->willReturn(FileInfo::SPACE_UNLIMITED); + + $folderId = $this->manager->createFolder('quota-etag-test'); + $this->seedFilecacheForFolder($folderId); + + $folderBefore = $this->manager->getFolder($folderId); + $this->assertNotNull($folderBefore); + $etagBefore = $folderBefore->rootCacheEntry->getEtag(); + + $this->manager->setFolderQuota($folderId, 1024 * 1024 * 1024); + + $folderAfter = $this->manager->getFolder($folderId); + $this->assertNotNull($folderAfter); + $etagAfter = $folderAfter->rootCacheEntry->getEtag(); + + $this->assertNotEquals( + $etagBefore, + $etagAfter, + 'Etag must change after quota update so desktop clients re-fetch quota-available-bytes via PROPFIND', + ); + + $this->assertSame( + 1024 * 1024 * 1024, + $folderAfter->quota, + 'Quota value must be persisted correctly', + ); + } + public function testQuotaDefaultValue(): void { $folderId1 = $this->manager->createFolder('foo');