-
+ {% for maintainerField in transferPackageForm.maintainers %}
+
- + {{ form_errors(maintainerField) }} + {{ form_widget(maintainerField) }} + + {% endfor %} +
diff --git a/css/app.scss b/css/app.scss index 32e1d7930d..fabc67e967 100644 --- a/css/app.scss +++ b/css/app.scss @@ -1082,14 +1082,18 @@ input:focus:invalid:focus, textarea:focus:invalid:focus, select:focus:invalid:fo margin-bottom: 4px; margin-right: 4px; } -.package .details #add-maintainer { +.package .details #add-maintainer{ margin-top: 3px; margin-bottom: 7px; - float: right; font-size: 34px; } +.package .details #transfer-package { + margin-top: 7px; + margin-bottom: 7px; + margin-right: 4px; + font-size: 34px; +} .package .details #remove-maintainer { - float: right; font-size: 35px; margin-top: 5px; } @@ -1884,3 +1888,25 @@ body { } } } + +#transfer-package-form { + .maintainers-collection-wrapper { + margin-bottom: 15px; + + .maintainers-list { + list-style: none; + padding: 0; + + li { + display: flex; + gap: 10px; + margin-bottom: 10px; + align-items: center; + + .btn-danger { + padding: 5px 10px; + } + } + } + } +} diff --git a/js/view.js b/js/view.js index d411a4ec91..5589167746 100644 --- a/js/view.js +++ b/js/view.js @@ -7,14 +7,23 @@ const init = function ($) { var versionCache = {}, ongoingRequest = false; - $('#add-maintainer').on('click', function (e) { + const togglePackageForm = function (selector) { $('#remove-maintainer-form').addClass('hidden'); - $('#add-maintainer-form').removeClass('hidden'); + $('#add-maintainer-form').addClass('hidden'); + $('#transfer-package-form').addClass('hidden'); + $(selector).removeClass('hidden'); + } + + $('#add-maintainer').on('click', function (e) { + togglePackageForm('#add-maintainer-form'); e.preventDefault(); }); $('#remove-maintainer').on('click', function (e) { - $('#add-maintainer-form').addClass('hidden'); - $('#remove-maintainer-form').removeClass('hidden'); + togglePackageForm('#remove-maintainer-form'); + e.preventDefault(); + }); + $('#transfer-package').on('click', function (e) { + togglePackageForm('#transfer-package-form'); e.preventDefault(); }); @@ -227,6 +236,38 @@ const init = function ($) { $(versionsList).css('max-height', 'inherit'); }); } + + // Handle add/remove buttons for transfer package form + $('.add-maintainer-item').on('click', function (e) { + e.preventDefault(); + + var list = $('.maintainers-list'); + var prototype = list.data('prototype'); + var index = list.find('li').length + 1; + + var newForm = prototype.replace(/__name__/g, index); + var newItem = $('
').append(newForm); + addMaintainerRemoveButton(newItem); + list.append(newItem); + }); + + $('.maintainers-list').find('li').each(function(index) { + addMaintainerRemoveButton($(this)); + }); + + function addMaintainerRemoveButton(item) { + var removeButton = $(''); + removeButton.on('click', function(e) { + e.preventDefault(); + + if ($('.maintainers-list').find('li').length === 1) { + return; + } + + item.remove(); + }); + item.append(removeButton); + } }; if (document.querySelector('#view-package-page')) { diff --git a/src/Command/TransferOwnershipCommand.php b/src/Command/TransferOwnershipCommand.php index c1cb011462..b31125ce00 100644 --- a/src/Command/TransferOwnershipCommand.php +++ b/src/Command/TransferOwnershipCommand.php @@ -15,6 +15,7 @@ use App\Entity\AuditRecord; use App\Entity\Package; use App\Entity\User; +use App\Model\PackageManager; use App\Util\DoctrineTrait; use Composer\Console\Input\InputOption; use Doctrine\Persistence\ManagerRegistry; @@ -30,6 +31,7 @@ class TransferOwnershipCommand extends Command public function __construct( private readonly ManagerRegistry $doctrine, + private readonly PackageManager $packageManager, ) { parent::__construct(); @@ -165,24 +167,8 @@ private function outputPackageTable(OutputInterface $output, array $packages, ar */ private function transferOwnership(array $packages, array $maintainers): void { - $normalizedMaintainers = array_values(array_map(fn (User $user) => $user->getId(), $maintainers)); - sort($normalizedMaintainers, SORT_NUMERIC); - foreach ($packages as $package) { - $oldMaintainers = $package->getMaintainers()->toArray(); - - $normalizedOldMaintainers = array_values(array_map(fn (User $user) => $user->getId(), $oldMaintainers)); - sort($normalizedOldMaintainers, SORT_NUMERIC); - if ($normalizedMaintainers === $normalizedOldMaintainers) { - continue; - } - - $package->getMaintainers()->clear(); - foreach ($maintainers as $maintainer) { - $package->addMaintainer($maintainer); - } - - $this->doctrine->getManager()->persist(AuditRecord::packageTransferred($package, null, $oldMaintainers, array_values($maintainers))); + $this->packageManager->transferPackage($package, $maintainers); } $this->doctrine->getManager()->flush(); diff --git a/src/Controller/FeedController.php b/src/Controller/FeedController.php index 1c4e9c82d6..016d1fbae1 100644 --- a/src/Controller/FeedController.php +++ b/src/Controller/FeedController.php @@ -130,7 +130,7 @@ public function extensionReleasesAction(Request $req): Response return $this->buildResponse($req, $feed); } - #[Route(path: '/package.{package}.{_format}', name: 'feed_package', requirements: ['_format' => '(rss|atom)', 'package' => '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+'], methods: ['GET'])] + #[Route(path: '/package.{package}.{_format}', name: 'feed_package', requirements: ['_format' => '(rss|atom)', 'package' => Package::PACKAGE_NAME_REGEX], methods: ['GET'])] public function packageAction(Request $req, string $package): Response { $repo = $this->doctrine->getRepository(Version::class); diff --git a/src/Controller/PackageController.php b/src/Controller/PackageController.php index e870a91587..8bdf50fed4 100644 --- a/src/Controller/PackageController.php +++ b/src/Controller/PackageController.php @@ -27,10 +27,12 @@ use App\Entity\Vendor; use App\Entity\Version; use App\Form\Model\MaintainerRequest; +use App\Form\Model\TransferPackageRequest; use App\Form\Type\AbandonedType; use App\Form\Type\AddMaintainerRequestType; use App\Form\Type\PackageType; use App\Form\Type\RemoveMaintainerRequestType; +use App\Form\Type\TransferPackageRequestType; use App\Model\DownloadManager; use App\Model\FavoriteManager; use App\Model\PackageManager; @@ -692,6 +694,7 @@ public function viewPackageAction(Request $req, string $name, CsrfTokenManagerIn $data['addMaintainerForm'] = $this->createAddMaintainerForm($package)->createView(); $data['removeMaintainerForm'] = $this->createRemoveMaintainerForm($package)->createView(); + $data['transferPackageForm'] = $this->createTransferPackageForm($package)->createView(); $data['deleteForm'] = $this->createDeletePackageForm($package)->createView(); } else { $data['hasVersionSecurityAdvisories'] = []; @@ -827,7 +830,7 @@ public function deletePackageVersionAction(Request $req, int $versionId): Respon return new Response('', 204); } - #[Route(path: '/packages/{name}', name: 'update_package', requirements: ['name' => '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+'], defaults: ['_format' => 'json'], methods: ['PUT'])] + #[Route(path: '/packages/{name}', name: 'update_package', requirements: ['name' => Package::PACKAGE_NAME_REGEX], defaults: ['_format' => 'json'], methods: ['PUT'])] public function updatePackageAction(Request $req, string $name, #[CurrentUser] User $user): Response { try { @@ -879,7 +882,7 @@ public function updatePackageAction(Request $req, string $name, #[CurrentUser] U return new JsonResponse(['status' => 'error', 'message' => 'Package was already updated in the last 24 hours'], 404); } - #[Route(path: '/packages/{name}', name: 'delete_package', requirements: ['name' => '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+'], methods: ['DELETE'])] + #[Route(path: '/packages/{name}', name: 'delete_package', requirements: ['name' => Package::PACKAGE_NAME_REGEX], methods: ['DELETE'])] public function deletePackageAction(Request $req, string $name): Response { $package = $this->getPartialPackageWithVersions($req, $name); @@ -904,7 +907,7 @@ public function deletePackageAction(Request $req, string $name): Response return new Response('Invalid form input', 400); } - #[Route(path: '/packages/{name:package}/maintainers/', name: 'add_maintainer', requirements: ['name' => '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+'])] + #[Route(path: '/packages/{name:package}/maintainers/', name: 'add_maintainer', requirements: ['name' => Package::PACKAGE_NAME_REGEX])] public function createMaintainerAction(Request $req, #[MapEntity] Package $package, #[CurrentUser] User $user, LoggerInterface $logger): RedirectResponse { $this->denyAccessUnlessGranted(PackageActions::AddMaintainer->value, $package); @@ -942,7 +945,7 @@ public function createMaintainerAction(Request $req, #[MapEntity] Package $packa return $this->redirectToRoute('view_package', ['name' => $package->getName()]); } - #[Route(path: '/packages/{name:package}/maintainers/delete', name: 'remove_maintainer', requirements: ['name' => '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+'])] + #[Route(path: '/packages/{name:package}/maintainers/delete', name: 'remove_maintainer', requirements: ['name' => Package::PACKAGE_NAME_REGEX])] public function removeMaintainerAction(Request $req, #[MapEntity] Package $package, #[CurrentUser] User $user, LoggerInterface $logger): Response { $this->denyAccessUnlessGranted(PackageActions::RemoveMaintainer->value, $package); @@ -986,6 +989,48 @@ public function removeMaintainerAction(Request $req, #[MapEntity] Package $packa ]); } + + #[Route(path: '/packages/{name:package}/transfer/', name: 'transfer_package', requirements: ['name' => Package::PACKAGE_NAME_REGEX])] + public function transferPackageAction(Request $req, #[MapEntity] Package $package, #[CurrentUser] User $user, LoggerInterface $logger): RedirectResponse + { + $this->denyAccessUnlessGranted(PackageActions::TransferPackage->value, $package); + + $form = $this->createTransferPackageForm($package); + $form->handleRequest($req); + + if (!$form->isSubmitted()) { + return $this->redirectToRoute('view_package', ['name' => $package->getName()]); + } + + if (!$form->isValid()) { + foreach ($form->getErrors(true, true) as $error) { + $this->addFlash('error', $error->getMessage()); + } + + return $this->redirectToRoute('view_package', ['name' => $package->getName()]); + } + + try { + $newMaintainers = $form->getData()->getMaintainers()->toArray(); + $result = $this->packageManager->transferPackage($package, $newMaintainers); + $this->getEM()->flush(); + + if ($result) { + $usernames = array_map(fn (User $user) => $user->getUsername(), $newMaintainers); + $this->addFlash('success', sprintf('Package has been transferred to %s', implode(', ', $usernames))); + } else { + $this->addFlash('warning', 'Package maintainers are identical and have not been changed'); + } + + return $this->redirectToRoute('view_package', ['name' => $package->getName()]); + } catch (\Exception $e) { + $logger->critical($e->getMessage(), ['exception', $e]); + $this->addFlash('error', 'The package could not be transferred.'); + } + + return $this->redirectToRoute('view_package', ['name' => $package->getName()]); + } + #[Route(path: '/packages/{name:package}/edit', name: 'edit_package', requirements: ['name' => '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?'])] public function editAction(Request $req, #[MapEntity] Package $package, #[CurrentUser] ?User $user = null): Response { @@ -1623,6 +1668,17 @@ private function createRemoveMaintainerForm(Package $package): FormInterface ]); } + /** + * @return FormInterface@@ -319,6 +324,33 @@ {% endif %} + {% if is_granted('transfer_package', package) %} +