Merge pull request #59932 from nextcloud/carl/copySkeleton

refactor: Move copy skeleton step to a file listener
This commit is contained in:
Ferdinand Thiessen 2026-04-29 23:15:38 +02:00 committed by GitHub
commit be2ac0bd86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 147 additions and 74 deletions

View file

@ -79,6 +79,7 @@ return array(
'OCA\\Files\\Listener\\NodeRemovedFromFavoriteListener' => $baseDir . '/../lib/Listener/NodeRemovedFromFavoriteListener.php',
'OCA\\Files\\Listener\\RenderReferenceEventListener' => $baseDir . '/../lib/Listener/RenderReferenceEventListener.php',
'OCA\\Files\\Listener\\SyncLivePhotosListener' => $baseDir . '/../lib/Listener/SyncLivePhotosListener.php',
'OCA\\Files\\Listener\\UserFirstTimeLoggedInListener' => $baseDir . '/../lib/Listener/UserFirstTimeLoggedInListener.php',
'OCA\\Files\\Migration\\Version11301Date20191205150729' => $baseDir . '/../lib/Migration/Version11301Date20191205150729.php',
'OCA\\Files\\Migration\\Version12101Date20221011153334' => $baseDir . '/../lib/Migration/Version12101Date20221011153334.php',
'OCA\\Files\\Migration\\Version2003Date20241021095629' => $baseDir . '/../lib/Migration/Version2003Date20241021095629.php',

View file

@ -94,6 +94,7 @@ class ComposerStaticInitFiles
'OCA\\Files\\Listener\\NodeRemovedFromFavoriteListener' => __DIR__ . '/..' . '/../lib/Listener/NodeRemovedFromFavoriteListener.php',
'OCA\\Files\\Listener\\RenderReferenceEventListener' => __DIR__ . '/..' . '/../lib/Listener/RenderReferenceEventListener.php',
'OCA\\Files\\Listener\\SyncLivePhotosListener' => __DIR__ . '/..' . '/../lib/Listener/SyncLivePhotosListener.php',
'OCA\\Files\\Listener\\UserFirstTimeLoggedInListener' => __DIR__ . '/..' . '/../lib/Listener/UserFirstTimeLoggedInListener.php',
'OCA\\Files\\Migration\\Version11301Date20191205150729' => __DIR__ . '/..' . '/../lib/Migration/Version11301Date20191205150729.php',
'OCA\\Files\\Migration\\Version12101Date20221011153334' => __DIR__ . '/..' . '/../lib/Migration/Version12101Date20221011153334.php',
'OCA\\Files\\Migration\\Version2003Date20241021095629' => __DIR__ . '/..' . '/../lib/Migration/Version2003Date20241021095629.php',

View file

@ -25,6 +25,7 @@ use OCA\Files\Listener\NodeAddedToFavoriteListener;
use OCA\Files\Listener\NodeRemovedFromFavoriteListener;
use OCA\Files\Listener\RenderReferenceEventListener;
use OCA\Files\Listener\SyncLivePhotosListener;
use OCA\Files\Listener\UserFirstTimeLoggedInListener;
use OCA\Files\Notification\Notifier;
use OCA\Files\Search\FilesSearchProvider;
use OCA\Files\Service\TagService;
@ -53,6 +54,7 @@ use OCP\IServerContainer;
use OCP\ITagManager;
use OCP\IUserSession;
use OCP\Share\IManager as IShareManager;
use OCP\User\Events\UserFirstTimeLoggedInEvent;
use OCP\Util;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
@ -122,6 +124,8 @@ class Application extends App implements IBootstrap {
$context->registerEventListener(LoadSearchPlugins::class, LoadSearchPluginsListener::class);
$context->registerEventListener(NodeAddedToFavorite::class, NodeAddedToFavoriteListener::class);
$context->registerEventListener(NodeRemovedFromFavorite::class, NodeRemovedFromFavoriteListener::class);
$context->registerEventListener(UserFirstTimeLoggedInEvent::class, UserFirstTimeLoggedInListener::class);
$context->registerSearchProvider(FilesSearchProvider::class);
$context->registerNotifierService(Notifier::class);

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files\Listener;
use OC\Files\Template\TemplateManager;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\ISetupManager;
use OCP\Files\NotPermittedException;
use OCP\User\Events\UserFirstTimeLoggedInEvent;
/**
* @template-implements IEventListener<UserFirstTimeLoggedInEvent>
*/
class UserFirstTimeLoggedInListener implements IEventListener {
public function __construct(
private readonly TemplateManager $templateManager,
private readonly ISetupManager $setupManager,
) {
}
public function handle(Event $event): void {
if (!$event instanceof UserFirstTimeLoggedInEvent) {
return;
}
$user = $event->getUser();
$this->setupManager->setupForUser($user);
try {
// copy skeleton
$this->templateManager->copySkeleton($user->getUID());
} catch (NotPermittedException) {
// read only uses
}
}
}

View file

@ -46,6 +46,7 @@ require_once 'public/Constants.php';
class OC {
/**
* The installation path for Nextcloud on the server (e.g. /srv/http/nextcloud)
* @internal Use auto-loaded $serverRoot with DI instead.
*/
public static string $SERVERROOT = '';
/**

View file

@ -64,9 +64,11 @@ class TemplateManager implements ITemplateManager {
private readonly IFactory $l10nFactory,
private readonly LoggerInterface $logger,
private readonly IFilenameValidator $filenameValidator,
private readonly string $serverRoot,
) {
$this->l10n = $l10nFactory->get('lib');
$this->userId = $userSession->getUser()?->getUID();
}
#[Override]
@ -321,8 +323,8 @@ class TemplateManager implements ITemplateManager {
$this->userId = $userId;
}
$defaultSkeletonDirectory = \OC::$SERVERROOT . '/core/skeleton';
$defaultTemplateDirectory = \OC::$SERVERROOT . '/core/skeleton/Templates';
$defaultSkeletonDirectory = $this->serverRoot . '/core/skeleton';
$defaultTemplateDirectory = $this->serverRoot . '/core/skeleton/Templates';
$skeletonPath = $this->config->getSystemValueString('skeletondirectory', $defaultSkeletonDirectory);
$skeletonTemplatePath = $this->config->getSystemValueString('templatedirectory', $defaultTemplateDirectory);
$isDefaultSkeleton = $skeletonPath === $defaultSkeletonDirectory;
@ -372,7 +374,7 @@ class TemplateManager implements ITemplateManager {
if (!$isDefaultTemplates && $folderIsEmpty) {
$localizedSkeletonTemplatePath = $this->getLocalizedTemplatePath($skeletonTemplatePath, $userLang);
if (!empty($localizedSkeletonTemplatePath) && file_exists($localizedSkeletonTemplatePath)) {
\OC_Util::copyr($localizedSkeletonTemplatePath, $folder);
$this->copyr($localizedSkeletonTemplatePath, $folder);
$userFolder->getStorage()->getScanner()->scan($folder->getInternalPath(), Scanner::SCAN_RECURSIVE);
$this->setTemplatePath($userTemplatePath);
return $userTemplatePath;
@ -382,7 +384,7 @@ class TemplateManager implements ITemplateManager {
if ($path !== null && $isDefaultSkeleton && $isDefaultTemplates && $folderIsEmpty) {
$localizedSkeletonPath = $this->getLocalizedTemplatePath($skeletonPath . '/Templates', $userLang);
if (!empty($localizedSkeletonPath) && file_exists($localizedSkeletonPath)) {
\OC_Util::copyr($localizedSkeletonPath, $folder);
$this->copyr($localizedSkeletonPath, $folder);
$userFolder->getStorage()->getScanner()->scan($folder->getInternalPath(), Scanner::SCAN_RECURSIVE);
$this->setTemplatePath($userTemplatePath);
return $userTemplatePath;
@ -413,4 +415,80 @@ class TemplateManager implements ITemplateManager {
return $localizedSkeletonTemplatePath;
}
/**
* Copies a local directory recursively by using streams
*/
private function copyr(string $source, Folder $target): void {
// Verify if folder exists
$dir = opendir($source);
if ($dir === false) {
$this->logger->error(sprintf('Could not opendir "%s"', $source), ['app' => 'core']);
return;
}
// Copy the files
while (false !== ($file = readdir($dir))) {
if (!Filesystem::isIgnoredDir($file)) {
if (is_dir($source . '/' . $file)) {
$child = $target->newFolder($file);
$this->copyr($source . '/' . $file, $child);
} else {
$sourceStream = fopen($source . '/' . $file, 'r');
if ($sourceStream === false) {
$this->logger->error(sprintf('Could not fopen "%s"', $source . '/' . $file), ['app' => 'core']);
closedir($dir);
return;
}
$target->newFile($file, $sourceStream);
}
}
}
closedir($dir);
}
public function copySkeleton(string $userId): void {
$user = $this->userManager->get($userId);
if ($user === null) {
throw new \LogicException('Trying to initialize home dir for a non-existent user');
}
$userDirectory = $this->rootFolder->getUserFolder($userId);
$plainSkeletonDirectory = $this->config->getSystemValueString('skeletondirectory', $this->serverRoot . '/core/skeleton');
$userLang = $this->l10nFactory->findLanguage();
$skeletonDirectory = str_replace('{lang}', $userLang, $plainSkeletonDirectory);
if (!file_exists($skeletonDirectory)) {
$dialectStart = strpos($userLang, '_');
if ($dialectStart !== false) {
$skeletonDirectory = str_replace('{lang}', substr($userLang, 0, $dialectStart), $plainSkeletonDirectory);
}
if ($dialectStart === false || !file_exists($skeletonDirectory)) {
$skeletonDirectory = str_replace('{lang}', 'default', $plainSkeletonDirectory);
}
if (!file_exists($skeletonDirectory)) {
$skeletonDirectory = '';
}
}
$instanceId = $this->config->getSystemValue('instanceid', '');
if ($instanceId === null) {
throw new \RuntimeException('no instance id!');
}
$appdata = 'appdata_' . $instanceId;
if ($userId === $appdata) {
throw new \RuntimeException('username is reserved name: ' . $appdata);
}
if (!empty($skeletonDirectory)) {
$this->logger->debug('copying skeleton for ' . $userId . ' from ' . $skeletonDirectory . ' to ' . $userDirectory->getFullPath('/'), ['app' => 'files_skeleton']);
$this->copyr($skeletonDirectory, $userDirectory);
// update the file cache
$userDirectory->getStorage()->getScanner()->scan('', Scanner::SCAN_RECURSIVE);
$this->initializeTemplateDirectory(null, $userId);
}
}
}

View file

@ -20,7 +20,6 @@ use OC\Hooks\PublicEmitter;
use OC\Http\CookieHelper;
use OC\Security\CSRF\CsrfTokenManager;
use OC_User;
use OC_Util;
use OCA\DAV\Connector\Sabre\Auth;
use OCP\AppFramework\Db\TTransactional;
use OCP\AppFramework\Utility\ITimeFactory;
@ -28,7 +27,6 @@ use OCP\Authentication\Exceptions\ExpiredTokenException;
use OCP\Authentication\Exceptions\InvalidTokenException;
use OCP\EventDispatcher\GenericEvent;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\NotPermittedException;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IRequest;
@ -534,21 +532,6 @@ class Session implements IUserSession, Emitter {
}
if ($firstTimeLogin) {
//we need to pass the user name, which may differ from login name
$user = $this->getUser()->getUID();
OC_Util::setupFS($user);
// TODO: lock necessary?
//trigger creation of user home and /files folder
$userFolder = \OC::$server->getUserFolder($user);
try {
// copy skeleton
\OC_Util::copySkeleton($user, $userFolder);
} catch (NotPermittedException $ex) {
// read only uses
}
// trigger any other initialization
Server::get(IEventDispatcher::class)->dispatch(IUser::class . '::firstLogin', new GenericEvent($this->getUser()));
Server::get(IEventDispatcher::class)->dispatchTyped(new UserFirstTimeLoggedInEvent($this->getUser()));

View file

@ -7,9 +7,9 @@
*/
use bantu\IniGetWrapper\IniGetWrapper;
use OC\Authentication\TwoFactorAuth\Manager as TwoFactorAuthManager;
use OC\Files\Cache\Scanner;
use OC\Files\Filesystem;
use OC\Files\SetupManager;
use OC\Files\Template\TemplateManager;
use OC\Setup;
use OC\SystemConfig;
use OCP\App\IAppManager;
@ -17,7 +17,6 @@ use OCP\Files\FileInfo;
use OCP\Files\Folder;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Files\Template\ITemplateManager;
use OCP\HintException;
use OCP\IConfig;
use OCP\IGroupManager;
@ -27,7 +26,6 @@ use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Security\ISecureRandom;
use OCP\Server;
use OCP\Share\IManager;
@ -116,49 +114,10 @@ class OC_Util {
* @param Folder $userDirectory
* @throws NotFoundException
* @throws NotPermittedException
* @suppress PhanDeprecatedFunction
* @deprecated 34.0.0 Not needed anymore, triggered automatically when UserFirstTimeLoggedInEvent is triggered
*/
public static function copySkeleton($userId, Folder $userDirectory) {
/** @var LoggerInterface $logger */
$logger = Server::get(LoggerInterface::class);
$plainSkeletonDirectory = Server::get(IConfig::class)->getSystemValueString('skeletondirectory', \OC::$SERVERROOT . '/core/skeleton');
$userLang = Server::get(IFactory::class)->findLanguage();
$skeletonDirectory = str_replace('{lang}', $userLang, $plainSkeletonDirectory);
if (!file_exists($skeletonDirectory)) {
$dialectStart = strpos($userLang, '_');
if ($dialectStart !== false) {
$skeletonDirectory = str_replace('{lang}', substr($userLang, 0, $dialectStart), $plainSkeletonDirectory);
}
if ($dialectStart === false || !file_exists($skeletonDirectory)) {
$skeletonDirectory = str_replace('{lang}', 'default', $plainSkeletonDirectory);
}
if (!file_exists($skeletonDirectory)) {
$skeletonDirectory = '';
}
}
$instanceId = Server::get(IConfig::class)->getSystemValue('instanceid', '');
if ($instanceId === null) {
throw new \RuntimeException('no instance id!');
}
$appdata = 'appdata_' . $instanceId;
if ($userId === $appdata) {
throw new \RuntimeException('username is reserved name: ' . $appdata);
}
if (!empty($skeletonDirectory)) {
$logger->debug('copying skeleton for ' . $userId . ' from ' . $skeletonDirectory . ' to ' . $userDirectory->getFullPath('/'), ['app' => 'files_skeleton']);
self::copyr($skeletonDirectory, $userDirectory);
// update the file cache
$userDirectory->getStorage()->getScanner()->scan('', Scanner::SCAN_RECURSIVE);
/** @var ITemplateManager $templateManager */
$templateManager = Server::get(ITemplateManager::class);
$templateManager->initializeTemplateDirectory(null, $userId);
}
Server::get(TemplateManager::class)->copySkeleton($userId);
}
/**
@ -167,6 +126,7 @@ class OC_Util {
* @param string $source
* @param Folder $target
* @return void
* @deprecated 34.0.0 Unused, if you really need this functionality, open an issue on GitHub
*/
public static function copyr($source, Folder $target) {
$logger = Server::get(LoggerInterface::class);

View file

@ -65,6 +65,7 @@
<file name="status.php"/>
<file name="version.php"/>
<file name="tests/lib/TestCase.php"/>
<file name="tests/lib/Files/Template/*.php"/>
<ignoreFiles>
<directory name="apps/**/tests"/>
<directory name="apps/**/composer"/>

View file

@ -7,7 +7,7 @@ declare(strict_types=1);
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace lib\Files\Template;
namespace Test\Files\Template;
use OC\AppFramework\Bootstrap\Coordinator;
use OC\AppFramework\Bootstrap\RegistrationContext;
@ -22,19 +22,18 @@ use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IL10N;
use OCP\IPreview;
use OCP\IServerContainer;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Container\ContainerInterface;
use Psr\Log\NullLogger;
use Test\TestCase;
class TemplateManagerTest extends TestCase {
private IRootFolder $rootFolder;
private Coordinator $bootstrapCoordinator;
private IRootFolder&MockObject $rootFolder;
private Coordinator&MockObject $bootstrapCoordinator;
private TemplateManager $templateManager;
#[\Override]
@ -59,7 +58,7 @@ class TemplateManagerTest extends TestCase {
$logger,
);
$serverContainer = $this->createMock(IServerContainer::class);
$serverContainer = $this->createMock(ContainerInterface::class);
$eventDispatcher = $this->createMock(IEventDispatcher::class);
$this->bootstrapCoordinator = $this->createMock(Coordinator::class);
$this->bootstrapCoordinator->method('getRegistrationContext')
@ -84,7 +83,8 @@ class TemplateManagerTest extends TestCase {
$config,
$l10nFactory,
$logger,
$filenameValidator
$filenameValidator,
\OC::$SERVERROOT,
);
}
@ -103,7 +103,7 @@ class TemplateManagerTest extends TestCase {
return $this->createMock(Folder::class);
});
$userFolder->method('nodeExists')
->willReturnCallback(function ($path) use ($filePath, $fileDirectory) {
->willReturnCallback(function ($path) use ($filePath, $fileDirectory): bool {
return $path === $fileDirectory;
});
$this->rootFolder->method('getUserFolder')