From 884a24c1d99558880590595a2a3c2c9c9058974e Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Thu, 13 Nov 2025 17:13:12 +0100
Subject: [PATCH 01/15] Add `PACKAGE_NAME_REGEX` constant
---
src/Controller/FeedController.php | 2 +-
src/Controller/PackageController.php | 8 ++++----
src/Entity/Package.php | 2 ++
src/Validator/ValidPackageRepositoryValidator.php | 2 +-
4 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/src/Controller/FeedController.php b/src/Controller/FeedController.php
index 1c4e9c82d..016d1fbae 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 e870a9158..194ce6600 100644
--- a/src/Controller/PackageController.php
+++ b/src/Controller/PackageController.php
@@ -827,7 +827,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 +879,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 +904,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 +942,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);
diff --git a/src/Entity/Package.php b/src/Entity/Package.php
index 29f68f57c..d4a5c78b4 100644
--- a/src/Entity/Package.php
+++ b/src/Entity/Package.php
@@ -90,6 +90,8 @@ class Package
public const AUTO_MANUAL_HOOK = 1;
public const AUTO_GITHUB_HOOK = 2;
+ public const string PACKAGE_NAME_REGEX = '[a-zA-Z0-9](?:[_.-]?[a-zA-Z0-9]+)*/[a-zA-Z0-9](?:[_.-]?[a-zA-Z0-9]+)*';
+
#[ORM\Id]
#[ORM\Column(type: 'integer')]
#[ORM\GeneratedValue(strategy: 'AUTO')]
diff --git a/src/Validator/ValidPackageRepositoryValidator.php b/src/Validator/ValidPackageRepositoryValidator.php
index 5f918baa3..f5484d57b 100644
--- a/src/Validator/ValidPackageRepositoryValidator.php
+++ b/src/Validator/ValidPackageRepositoryValidator.php
@@ -90,7 +90,7 @@ public function validate(mixed $value, Constraint $constraint): void
}
$name = $value->getName();
- if (!Preg::isMatch('{^[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9]([_.-]?[a-z0-9]+)*$}iD', $name)) {
+ if (!Preg::isMatch(sprintf('{^%s$}D', Package::PACKAGE_NAME_REGEX), $name)) {
$this->addViolation('The package name '.htmlentities($name, \ENT_COMPAT, 'utf-8').' is invalid, it should have a vendor name, a forward slash, and a package name. The vendor and package name can be words separated by -, . or _. The complete name should match "[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9]([_.-]?[a-z0-9]+)*".');
return;
From 4729629c6fb438702000e1e2b958e7de9cc01f4c Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Thu, 13 Nov 2025 21:12:02 +0100
Subject: [PATCH 02/15] Add `TransferPackage` action to `PackageVoter`
---
src/Security/Voter/PackageActions.php | 1 +
src/Security/Voter/PackageVoter.php | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/Security/Voter/PackageActions.php b/src/Security/Voter/PackageActions.php
index 71d4c3d24..f21ea67d0 100644
--- a/src/Security/Voter/PackageActions.php
+++ b/src/Security/Voter/PackageActions.php
@@ -17,6 +17,7 @@ enum PackageActions: string
case Edit = 'edit';
case AddMaintainer = 'add_maintainer';
case RemoveMaintainer = 'remove_maintainer';
+ case TransferPackage = 'transfer_package';
case Abandon = 'abandon';
case Delete = 'delete';
case DeleteVersion = 'delete_version';
diff --git a/src/Security/Voter/PackageVoter.php b/src/Security/Voter/PackageVoter.php
index 7ee873554..d970b9fed 100644
--- a/src/Security/Voter/PackageVoter.php
+++ b/src/Security/Voter/PackageVoter.php
@@ -54,7 +54,7 @@ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInter
PackageActions::Delete => $this->canDelete($package, $user),
PackageActions::DeleteVersion => $this->canDeleteVersion($package, $user),
PackageActions::Edit => $this->canEdit($package, $user),
- PackageActions::AddMaintainer => $this->canAddMaintainers($package, $user),
+ PackageActions::AddMaintainer, PackageActions::TransferPackage => $this->canAddMaintainers($package, $user),
PackageActions::RemoveMaintainer => $this->canRemoveMaintainers($package, $user),
PackageActions::Update => $package->isMaintainer($user) || $this->security->isGranted('ROLE_UPDATE_PACKAGES'),
};
From 99eee3b36978c89701add0b183f6fb5710548142 Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Thu, 13 Nov 2025 21:13:20 +0100
Subject: [PATCH 03/15] Create the `TransferPackageRequestType` form
---
src/Form/Model/TransferPackageRequest.php | 44 ++++++++++++++
src/Form/Type/MaintainerType.php | 62 ++++++++++++++++++++
src/Form/Type/TransferPackageRequestType.php | 56 ++++++++++++++++++
3 files changed, 162 insertions(+)
create mode 100644 src/Form/Model/TransferPackageRequest.php
create mode 100644 src/Form/Type/MaintainerType.php
create mode 100644 src/Form/Type/TransferPackageRequestType.php
diff --git a/src/Form/Model/TransferPackageRequest.php b/src/Form/Model/TransferPackageRequest.php
new file mode 100644
index 000000000..afc957e67
--- /dev/null
+++ b/src/Form/Model/TransferPackageRequest.php
@@ -0,0 +1,44 @@
+
+ * Nils Adermann
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace App\Form\Model;
+
+use App\Entity\User;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
+
+class TransferPackageRequest
+{
+ /** @var Collection */
+ private Collection $maintainers;
+
+ public function __construct()
+ {
+ $this->maintainers = new ArrayCollection();
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getMaintainers(): Collection
+ {
+ return $this->maintainers;
+ }
+
+ /**
+ * @param Collection $maintainers
+ */
+ public function setMaintainers(Collection $maintainers): void
+ {
+ $this->maintainers = $maintainers;
+ }
+}
diff --git a/src/Form/Type/MaintainerType.php b/src/Form/Type/MaintainerType.php
new file mode 100644
index 000000000..c453db5a0
--- /dev/null
+++ b/src/Form/Type/MaintainerType.php
@@ -0,0 +1,62 @@
+
+ * Nils Adermann
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace App\Form\Type;
+
+use App\Entity\User;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Form\CallbackTransformer;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+/**
+ * @extends AbstractType
+ */
+class MaintainerType extends AbstractType
+{
+ public function __construct(
+ private readonly EntityManagerInterface $em,
+ ) {
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder->addModelTransformer(new CallbackTransformer(
+ function (?User $user): string {
+ return $user?->getUsername() ?? '';
+ },
+ function (?string $username): ?User {
+ if (!$username) {
+ return null;
+ }
+
+ return $this->em->getRepository(User::class)->findOneByUsernameOrEmail($username);
+ }
+ ));
+ }
+
+ public function getParent(): string
+ {
+ return TextType::class;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'attr' => [
+ 'placeholder' => 'Username or email',
+ ],
+ ]);
+ }
+}
diff --git a/src/Form/Type/TransferPackageRequestType.php b/src/Form/Type/TransferPackageRequestType.php
new file mode 100644
index 000000000..173cc934a
--- /dev/null
+++ b/src/Form/Type/TransferPackageRequestType.php
@@ -0,0 +1,56 @@
+
+ * Nils Adermann
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace App\Form\Type;
+
+use App\Form\Model\TransferPackageRequest;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\CollectionType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+/**
+ * @extends AbstractType
+ */
+class TransferPackageRequestType extends AbstractType
+{
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder->add('maintainers', CollectionType::class, [
+ 'entry_type' => MaintainerType::class,
+ 'entry_options' => [
+ 'label' => false,
+ ],
+ 'allow_add' => true,
+ 'allow_delete' => true,
+ 'delete_empty' => true,
+ 'prototype' => true,
+ 'prototype_name' => '__name__',
+ 'label' => 'New Maintainers',
+ 'attr' => [
+ 'class' => 'maintainers-collection',
+ ],
+ ]);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => TransferPackageRequest::class,
+ ]);
+ }
+
+ public function getBlockPrefix(): string
+ {
+ return 'transfer_package_form';
+ }
+}
From 62d6807fe56b0066ca2a723370c071981c8be679 Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Thu, 13 Nov 2025 21:26:42 +0100
Subject: [PATCH 04/15] Create the form to transfer package ownership
---
css/app.scss | 49 +++++++++++++++++++++++-
js/view.js | 49 ++++++++++++++++++++++--
src/Controller/PackageController.php | 49 ++++++++++++++++++++++++
src/Model/PackageManager.php | 27 +++++++++++++
templates/package/view_package.html.twig | 28 ++++++++++++++
5 files changed, 197 insertions(+), 5 deletions(-)
diff --git a/css/app.scss b/css/app.scss
index 32e1d7930..91070a7f9 100644
--- a/css/app.scss
+++ b/css/app.scss
@@ -1082,12 +1082,19 @@ 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;
+ float: right;
+ font-size: 34px;
+}
.package .details #remove-maintainer {
float: right;
font-size: 35px;
@@ -1884,3 +1891,43 @@ body {
}
}
}
+
+#transfer-package-form {
+ .maintainers-collection-wrapper {
+ margin-bottom: 15px;
+
+ label {
+ font-weight: bold;
+ margin-bottom: 10px;
+ }
+
+ .maintainers-list {
+ list-style: none;
+ padding: 0;
+ margin-bottom: 10px;
+
+ li {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 10px;
+ align-items: center;
+
+ input[type="text"] {
+ flex: 1;
+ padding: 6px 12px;
+ font-size: 14px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ }
+
+ .btn-danger {
+ flex-shrink: 0;
+ }
+ }
+ }
+
+ .add-maintainer-item {
+ margin-top: 5px;
+ }
+ }
+}
diff --git a/js/view.js b/js/view.js
index d411a4ec9..558916774 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/Controller/PackageController.php b/src/Controller/PackageController.php
index 194ce6600..11370cbeb 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'] = [];
@@ -986,6 +989,41 @@ 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);
+
+ $oldMaintainers = $package->getMaintainers()->toArray();
+
+ $form = $this->createTransferPackageForm($package);
+ $form->handleRequest($req);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ try {
+ $em = $this->getEM();
+ $newMaintainers = $form->getData()->getMaintainers()->toArray();
+ $result = $this->packageManager->transferPackage($package, $oldMaintainers, $newMaintainers);
+ $em->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 +1661,17 @@ private function createRemoveMaintainerForm(Package $package): FormInterface
]);
}
+ /**
+ * @return FormInterface
+ */
+ private function createTransferPackageForm(Package $package): FormInterface
+ {
+ $transferRequest = new TransferPackageRequest();
+ $transferRequest->setMaintainers($package->getMaintainers());
+
+ return $this->createForm(TransferPackageRequestType::class, $transferRequest);
+ }
+
/**
* @return FormInterface
*/
diff --git a/src/Model/PackageManager.php b/src/Model/PackageManager.php
index b1a171c38..b967b0d7c 100644
--- a/src/Model/PackageManager.php
+++ b/src/Model/PackageManager.php
@@ -14,6 +14,7 @@
use Algolia\AlgoliaSearch\Exceptions\AlgoliaException;
use Algolia\AlgoliaSearch\SearchClient;
+use App\Entity\AuditRecord;
use App\Entity\Dependent;
use App\Entity\Download;
use App\Entity\EmptyReferenceCache;
@@ -239,4 +240,30 @@ public function notifyNewMaintainer(User $user, Package $package): bool
return true;
}
+
+ /*
+ * @param User[] $oldMaintainers
+ * @param User[] $newMaintainers
+ */
+ public function transferPackage(Package $package, array $oldMaintainers, array $newMaintainers): bool
+ {
+ $normalizedOldMaintainers = array_values(array_map(fn (User $user) => $user->getId(), $oldMaintainers));
+ sort($normalizedOldMaintainers, SORT_NUMERIC);
+
+ $normalizedMaintainers = array_values(array_map(fn (User $user) => $user->getId(), $newMaintainers));
+ sort($normalizedMaintainers, SORT_NUMERIC);
+
+ if ($normalizedMaintainers === $normalizedOldMaintainers) {
+ return false;
+ }
+
+ $package->getMaintainers()->clear();
+ foreach ($newMaintainers as $maintainer) {
+ $package->addMaintainer($maintainer);
+ }
+
+ $this->doctrine->getManager()->persist(AuditRecord::packageTransferred($package, null, $oldMaintainers, $newMaintainers));
+
+ return true;
+ }
}
diff --git a/templates/package/view_package.html.twig b/templates/package/view_package.html.twig
index 04868460e..7a99b0a31 100644
--- a/templates/package/view_package.html.twig
+++ b/templates/package/view_package.html.twig
@@ -146,6 +146,7 @@
{% endfor %}
{% if is_granted('remove_maintainer', package) %}{% endif %}
{% if is_granted('add_maintainer', package) %}{% endif %}
+ {% if is_granted('transfer_package', package) %}{% endif %}
Details
@@ -319,6 +320,33 @@
{% endif %}
+ {% if is_granted('transfer_package', package) %}
+
+ {{ form_start(transferPackageForm, {
+ attr: { id: 'transfer-package-form', class: 'col-sm-6 col-md-3 col-md-offset-9 ' ~ (show_transfer_package_form|default(false) ? '': 'hidden') },
+ action: path('transfer_package', { 'name': package.name })
+ }) }}
+
+
Transfer Ownership
+
+ {{ form_errors(transferPackageForm.maintainers) }}
+
+ {% for maintainerField in transferPackageForm.maintainers %}
+ -
+ {{ form_errors(maintainerField) }}
+ {{ form_widget(maintainerField) }}
+
+ {% endfor %}
+
+
+
+ {{ form_rest(transferPackageForm) }}
+
+
+ {{ form_end(transferPackageForm) }}
+
+ {% endif %}
+
{% if versions|length %}
From 9fc3165ba53380f7dfd1cfc191827ceeee427abf Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Thu, 13 Nov 2025 22:09:59 +0100
Subject: [PATCH 05/15] Add form validation to `transferPackageAction()`
---
src/Controller/PackageController.php | 4 ++
src/Form/Model/TransferPackageRequest.php | 2 +
src/Form/Type/MaintainerType.php | 16 ++++++-
.../TransferPackageValidMaintainersList.php | 21 +++++++++
...erPackageValidMaintainersListValidator.php | 46 +++++++++++++++++++
5 files changed, 87 insertions(+), 2 deletions(-)
create mode 100644 src/Validator/Constraints/TransferPackageValidMaintainersList.php
create mode 100644 src/Validator/Constraints/TransferPackageValidMaintainersListValidator.php
diff --git a/src/Controller/PackageController.php b/src/Controller/PackageController.php
index 11370cbeb..180a321d6 100644
--- a/src/Controller/PackageController.php
+++ b/src/Controller/PackageController.php
@@ -1019,6 +1019,10 @@ public function transferPackageAction(Request $req, #[MapEntity] Package $packag
$logger->critical($e->getMessage(), ['exception', $e]);
$this->addFlash('error', 'The package could not be transferred.');
}
+ } elseif (!$form->isValid()) {
+ foreach ($form->getErrors(true, true) as $error) {
+ $this->addFlash('error', $error->getMessage());
+ }
}
return $this->redirectToRoute('view_package', ['name' => $package->getName()]);
diff --git a/src/Form/Model/TransferPackageRequest.php b/src/Form/Model/TransferPackageRequest.php
index afc957e67..747debb94 100644
--- a/src/Form/Model/TransferPackageRequest.php
+++ b/src/Form/Model/TransferPackageRequest.php
@@ -13,12 +13,14 @@
namespace App\Form\Model;
use App\Entity\User;
+use App\Validator\Constraints\TransferPackageValidMaintainersList;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
class TransferPackageRequest
{
/** @var Collection */
+ #[TransferPackageValidMaintainersList]
private Collection $maintainers;
public function __construct()
diff --git a/src/Form/Type/MaintainerType.php b/src/Form/Type/MaintainerType.php
index c453db5a0..930c9457f 100644
--- a/src/Form/Type/MaintainerType.php
+++ b/src/Form/Type/MaintainerType.php
@@ -15,9 +15,10 @@
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\CallbackTransformer;
+use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
-use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -41,7 +42,18 @@ function (?string $username): ?User {
return null;
}
- return $this->em->getRepository(User::class)->findOneByUsernameOrEmail($username);
+ $user = $this->em->getRepository(User::class)->findOneByUsernameOrEmail($username);
+
+ if ($user === null) {
+ $failure = new TransformationFailedException(sprintf('User "%s" does not exist.', $username));
+ $failure->setInvalidMessage('The given "{{ value }}" value is not a valid username or email.', [
+ '{{ value }}' => $username,
+ ]);
+
+ throw $failure;
+ }
+
+ return $user;
}
));
}
diff --git a/src/Validator/Constraints/TransferPackageValidMaintainersList.php b/src/Validator/Constraints/TransferPackageValidMaintainersList.php
new file mode 100644
index 000000000..f423e9d57
--- /dev/null
+++ b/src/Validator/Constraints/TransferPackageValidMaintainersList.php
@@ -0,0 +1,21 @@
+
+ * Nils Adermann
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace App\Validator\Constraints;
+
+use Symfony\Component\Validator\Constraint;
+
+#[\Attribute]
+class TransferPackageValidMaintainersList extends Constraint
+{
+ public string $emptyMessage = 'At least one maintainer must be specified.';
+}
diff --git a/src/Validator/Constraints/TransferPackageValidMaintainersListValidator.php b/src/Validator/Constraints/TransferPackageValidMaintainersListValidator.php
new file mode 100644
index 000000000..3036b697b
--- /dev/null
+++ b/src/Validator/Constraints/TransferPackageValidMaintainersListValidator.php
@@ -0,0 +1,46 @@
+
+ * Nils Adermann
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace App\Validator\Constraints;
+
+use App\Entity\User;
+use App\Form\Model\InvalidMaintainer;
+use Doctrine\Common\Collections\Collection;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\ConstraintValidator;
+use Symfony\Component\Validator\Exception\UnexpectedTypeException;
+use Symfony\Component\Validator\Exception\UnexpectedValueException;
+
+class TransferPackageValidMaintainersListValidator extends ConstraintValidator
+{
+ public function validate(mixed $value, Constraint $constraint): void
+ {
+ if (!$constraint instanceof TransferPackageValidMaintainersList) {
+ throw new UnexpectedTypeException($constraint, TransferPackageValidMaintainersList::class);
+ }
+
+ if (null === $value) {
+ return;
+ }
+
+ if (!$value instanceof Collection) {
+ throw new UnexpectedValueException($value, Collection::class);
+ }
+
+ if (!$value->isEmpty()) {
+ return;
+ }
+
+ $this->context->buildViolation($constraint->emptyMessage)
+ ->addViolation();
+ }
+}
From e679bfbb21be5837bb893d5182c714dbb12e874d Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Fri, 14 Nov 2025 08:38:45 +0100
Subject: [PATCH 06/15] Move maintainer actions below the list of maintainers
---
css/app.scss | 3 ---
templates/package/view_package.html.twig | 10 +++++++---
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/css/app.scss b/css/app.scss
index 91070a7f9..02fddc488 100644
--- a/css/app.scss
+++ b/css/app.scss
@@ -1085,18 +1085,15 @@ input:focus:invalid:focus, textarea:focus:invalid:focus, select:focus:invalid:fo
.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;
- float: right;
font-size: 34px;
}
.package .details #remove-maintainer {
- float: right;
font-size: 35px;
margin-top: 5px;
}
diff --git a/templates/package/view_package.html.twig b/templates/package/view_package.html.twig
index 7a99b0a31..470d2e6e6 100644
--- a/templates/package/view_package.html.twig
+++ b/templates/package/view_package.html.twig
@@ -144,10 +144,14 @@
{% for maintainer in package.maintainers -%}
{% endfor %}
- {% if is_granted('remove_maintainer', package) %}{% endif %}
+
+
+ {% if is_granted('remove_maintainer', package) or is_granted('add_maintainer', package) or is_granted('transfer_package', package) %}
+ Maintainer actions
{% if is_granted('add_maintainer', package) %}{% endif %}
+ {% if is_granted('remove_maintainer', package) %}{% endif %}
{% if is_granted('transfer_package', package) %}{% endif %}
-
+ {% endif %}
Details
{% set repoUrl = package.browsableRepository %}
@@ -338,7 +342,7 @@
{% endfor %}
-
+
{{ form_rest(transferPackageForm) }}
From 5cb5b9307c6c7245fe78fa1d9cb474eabe184bd2 Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Fri, 14 Nov 2025 08:46:26 +0100
Subject: [PATCH 07/15] Add tests for
`PackageController::transferPackageAction()`
---
src/Command/TransferOwnershipCommand.php | 20 +----
src/Controller/PackageController.php | 9 +-
src/Model/PackageManager.php | 3 +-
.../Command/TransferOwnershipCommandTest.php | 63 +-------------
tests/Controller/PackageControllerTest.php | 79 +++++++++++++++++
tests/Model/PackageManagerTest.php | 85 ++++++++++++++++++-
6 files changed, 172 insertions(+), 87 deletions(-)
diff --git a/src/Command/TransferOwnershipCommand.php b/src/Command/TransferOwnershipCommand.php
index c1cb01146..b31125ce0 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/PackageController.php b/src/Controller/PackageController.php
index 180a321d6..85e9ed79d 100644
--- a/src/Controller/PackageController.php
+++ b/src/Controller/PackageController.php
@@ -995,17 +995,14 @@ public function transferPackageAction(Request $req, #[MapEntity] Package $packag
{
$this->denyAccessUnlessGranted(PackageActions::TransferPackage->value, $package);
- $oldMaintainers = $package->getMaintainers()->toArray();
-
$form = $this->createTransferPackageForm($package);
$form->handleRequest($req);
if ($form->isSubmitted() && $form->isValid()) {
try {
- $em = $this->getEM();
$newMaintainers = $form->getData()->getMaintainers()->toArray();
- $result = $this->packageManager->transferPackage($package, $oldMaintainers, $newMaintainers);
- $em->flush();
+ $result = $this->packageManager->transferPackage($package, $newMaintainers);
+ $this->getEM()->flush();
if ($result) {
$usernames = array_map(fn (User $user) => $user->getUsername(), $newMaintainers);
@@ -1671,7 +1668,7 @@ private function createRemoveMaintainerForm(Package $package): FormInterface
private function createTransferPackageForm(Package $package): FormInterface
{
$transferRequest = new TransferPackageRequest();
- $transferRequest->setMaintainers($package->getMaintainers());
+ $transferRequest->setMaintainers(clone $package->getMaintainers());
return $this->createForm(TransferPackageRequestType::class, $transferRequest);
}
diff --git a/src/Model/PackageManager.php b/src/Model/PackageManager.php
index b967b0d7c..4f5db1abd 100644
--- a/src/Model/PackageManager.php
+++ b/src/Model/PackageManager.php
@@ -245,8 +245,9 @@ public function notifyNewMaintainer(User $user, Package $package): bool
* @param User[] $oldMaintainers
* @param User[] $newMaintainers
*/
- public function transferPackage(Package $package, array $oldMaintainers, array $newMaintainers): bool
+ public function transferPackage(Package $package, array $newMaintainers): bool
{
+ $oldMaintainers = $package->getMaintainers()->toArray();
$normalizedOldMaintainers = array_values(array_map(fn (User $user) => $user->getId(), $oldMaintainers));
sort($normalizedOldMaintainers, SORT_NUMERIC);
diff --git a/tests/Command/TransferOwnershipCommandTest.php b/tests/Command/TransferOwnershipCommandTest.php
index ef167fc6d..d21946a80 100644
--- a/tests/Command/TransferOwnershipCommandTest.php
+++ b/tests/Command/TransferOwnershipCommandTest.php
@@ -12,14 +12,12 @@
namespace App\Tests\Command;
-use App\Audit\AuditRecordType;
use App\Command\TransferOwnershipCommand;
-use App\Entity\AuditRecord;
use App\Entity\Package;
use App\Entity\User;
+use App\Model\PackageManager;
use App\Tests\IntegrationTestCase;
use Doctrine\Persistence\ManagerRegistry;
-use PHPUnit\Framework\Attributes\TestWith;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
@@ -45,7 +43,7 @@ protected function setUp(): void
$this->package3 = self::createPackage('vendor2/package1', 'https://github.com/vendor2/package1',maintainers: [$john]);
$this->store($this->package1, $this->package2, $this->package3);
- $command = new TransferOwnershipCommand(self::getContainer()->get(ManagerRegistry::class));
+ $command = new TransferOwnershipCommand(self::getContainer()->get(ManagerRegistry::class), self::getContainer()->get(PackageManager::class));
$this->commandTester = new CommandTester($command);
}
@@ -73,9 +71,6 @@ public function testExecuteSuccessForVendor(): void
$this->assertEqualsCanonicalizing(['alice', 'bob'], array_map($callable, $package1->getMaintainers()->toArray()));
$this->assertEqualsCanonicalizing(['alice', 'bob'], array_map($callable, $package2->getMaintainers()->toArray()));
$this->assertEqualsCanonicalizing(['john'], array_map($callable, $package3->getMaintainers()->toArray()), 'vendor2 packages should not be changed');
-
- $this->assertAuditLogWasCreated($package1, ['john', 'alice'], ['alice', 'bob']);
- $this->assertAuditLogWasCreated($package2, ['john', 'bob'], ['alice', 'bob']);
}
public function testExecuteSuccessForPackage(): void
@@ -99,8 +94,6 @@ public function testExecuteSuccessForPackage(): void
$callable = fn (User $user) => $user->getUsernameCanonical();
$this->assertEqualsCanonicalizing(['bob', 'john'], array_map($callable, $package2->getMaintainers()->toArray()), 'vendor1 packages should not be changed');
$this->assertEqualsCanonicalizing(['alice', 'john'], array_map($callable, $package3->getMaintainers()->toArray()));
-
- $this->assertAuditLogWasCreated($package3, ['john'], ['alice', 'john']);
}
public function testExecuteWithDryRunDoesNothing(): void
@@ -126,33 +119,6 @@ public function testExecuteWithDryRunDoesNothing(): void
$this->assertEqualsCanonicalizing(['john', 'alice'], array_map($callable, $package1->getMaintainers()->toArray()));
}
- public function testExecuteIgnoresIdenticalMaintainers(): void
- {
- $this->commandTester->execute([
- 'vendorOrPackage' => 'vendor1',
- 'maintainers' => ['alice', 'john'],
- ]);
-
- $this->commandTester->assertCommandIsSuccessful();
-
- $em = self::getEM();
- $em->clear();
-
- $package1 = $em->find(Package::class, $this->package1->getId());
- $package2 = $em->find(Package::class, $this->package2->getId());
-
- $this->assertNotNull($package1);
- $this->assertNotNull($package2);
-
- $callable = fn (User $user) => $user->getUsernameCanonical();
- $this->assertEqualsCanonicalizing(['alice', 'john'], array_map($callable, $package1->getMaintainers()->toArray()));
- $this->assertEqualsCanonicalizing(['alice', 'john'], array_map($callable, $package2->getMaintainers()->toArray()));
-
- $record = $this->retrieveAuditRecordForPackage($package1);
- $this->assertNull($record, 'No audit log should be created if package maintainers are identical');
- $this->assertAuditLogWasCreated($package2, ['john', 'bob'], ['alice', 'john']);
- }
-
public function testExecuteFailsWithUnknownMaintainers(): void
{
$this->commandTester->execute([
@@ -178,29 +144,4 @@ public function testExecuteFailsIfNoVendorPackagesFound(): void
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('No packages found for foobar', $output);
}
-
- /**
- * @param string[] $oldMaintainers
- * @param string[] $newMaintainers
- */
- private function assertAuditLogWasCreated(Package $package, array $oldMaintainers, array $newMaintainers): void
- {
- $record = $this->retrieveAuditRecordForPackage($package);
- $this->assertNotNull($record);
- $this->assertSame('admin', $record->attributes['actor']);
- $this->assertSame($package->getId(), $record->packageId);
-
- $callable = fn (array $user) => $user['username'];
- $this->assertEqualsCanonicalizing($oldMaintainers, array_map($callable, $record->attributes['previous_maintainers']));
- $this->assertEqualsCanonicalizing($newMaintainers, array_map($callable, $record->attributes['current_maintainers']));
- }
-
- private function retrieveAuditRecordForPackage(Package $package): ?AuditRecord
- {
- return $this->getEM()->getRepository(AuditRecord::class)->findOneBy([
- 'type' => AuditRecordType::PackageTransferred->value,
- 'packageId' => $package->getId(),
- 'actorId' => null,
- ]);
- }
}
diff --git a/tests/Controller/PackageControllerTest.php b/tests/Controller/PackageControllerTest.php
index 6c5214185..b44d064d2 100644
--- a/tests/Controller/PackageControllerTest.php
+++ b/tests/Controller/PackageControllerTest.php
@@ -16,6 +16,7 @@
use App\Entity\Package;
use App\Entity\User;
use App\Tests\IntegrationTestCase;
+use PHPUnit\Framework\Attributes\TestWith;
class PackageControllerTest extends IntegrationTestCase
{
@@ -144,4 +145,82 @@ public function testRemoveMaintainer(): void
$this->assertNotNull($auditRecord);
}
+
+ public function testTransferPackage(): void
+ {
+ $john = self::createUser('john', 'john@example.org');
+ $alice = self::createUser('alice', 'alice@example.org');
+ $bob = self::createUser('bob', 'bob@example.org');
+ $package = self::createPackage('test/pkg', 'https://example.com/test/pkg', maintainers: [$john, $alice]);
+
+ $this->store($john, $alice, $bob, $package);
+
+ $this->client->loginUser($john);
+
+ $this->assertTrue($package->isMaintainer($john));
+ $this->assertTrue($package->isMaintainer($alice));
+ $this->assertFalse($package->isMaintainer($bob));
+
+ $crawler = $this->client->request('GET', '/packages/test/pkg');
+
+ $form = $crawler->filter('[name="transfer_package_form"]')->form();
+ $form->setValues([
+ 'transfer_package_form[maintainers][0]' => 'alice',
+ 'transfer_package_form[maintainers][1]' => 'bob@example.org',
+ ]);
+
+ $this->client->submit($form);
+
+ $this->assertResponseRedirects('/packages/test/pkg');
+ $this->client->followRedirect();
+ $this->assertResponseIsSuccessful();
+
+ $em = self::getEM();
+ $em->clear();
+
+ $package = $em->getRepository(Package::class)->find($package->getId());
+ $this->assertNotNull($package);
+
+ $maintainerIds = array_map(fn (User $user) => $user->getId(), $package->getMaintainers()->toArray());
+ $this->assertContains($alice->getId(), $maintainerIds);
+ $this->assertContains($bob->getId(), $maintainerIds);
+ $this->assertNotContains($john->getId(), $maintainerIds);
+
+ $auditRecord = $em->getRepository(\App\Entity\AuditRecord::class)->findOneBy([
+ 'type' => AuditRecordType::PackageTransferred->value,
+ 'packageId' => $package->getId(),
+ ]);
+
+ $this->assertNotNull($auditRecord, 'Audit record not found');
+ }
+
+ #[TestWith(['does_not_exist', 'value is not a valid username or email'])]
+ #[TestWith([null, 'at least one maintainer must be specified'])]
+ public function testTransferPackageReturnsValidationError(?string $value, string $message): void
+ {
+ $alice = self::createUser('alice', 'alice@example.org');
+ $package = self::createPackage('test/pkg', 'https://example.com/test/pkg', maintainers: [$alice]);
+
+ $this->store($alice, $package);
+
+ $this->client->loginUser($alice);
+
+ $crawler = $this->client->request('GET', '/packages/test/pkg');
+
+ $form = $crawler->filter('[name="transfer_package_form"]')->form();
+ $form->setValues([
+ 'transfer_package_form[maintainers][0]' => $value,
+ ]);
+
+ $this->client->submit($form);
+
+ $this->assertResponseRedirects('/packages/test/pkg');
+ $crawler = $this->client->followRedirect();
+ $this->assertResponseIsSuccessful();
+
+ $elements = $crawler->filter('.flash-container .alert-error');
+ $this->assertCount(1, $elements);
+ $text = $elements->text();
+ $this->assertStringContainsStringIgnoringCase($message, $text);
+ }
}
diff --git a/tests/Model/PackageManagerTest.php b/tests/Model/PackageManagerTest.php
index acfa944e7..fd5f56eae 100644
--- a/tests/Model/PackageManagerTest.php
+++ b/tests/Model/PackageManagerTest.php
@@ -12,11 +12,24 @@
namespace App\Tests\Model;
+use App\Audit\AuditRecordType;
+use App\Entity\AuditRecord;
use App\Entity\Package;
-use PHPUnit\Framework\TestCase;
+use App\Entity\User;
+use App\Model\PackageManager;
+use App\Tests\IntegrationTestCase;
-class PackageManagerTest extends TestCase
+class PackageManagerTest extends IntegrationTestCase
{
+ private PackageManager $packageManager;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->packageManager = self::getContainer()->get(PackageManager::class);
+ }
+
public function testNotifyFailure(): void
{
$this->markTestSkipped('Do it!');
@@ -46,4 +59,72 @@ public function testNotifyFailure(): void
$client->request('POST', '/api/github?username=test&apiToken=token', ['payload' => $payload]);
$this->assertEquals(202, $client->getResponse()->getStatusCode());
}
+
+ public function testTransferPackageReplacesAllMaintainers(): void
+ {
+ $alice = self::createUser('alice', 'alice@example.org');
+ $bob = self::createUser('bob', 'bob@example.org');
+ $john = self::createUser('john', 'john@example.org');
+ $this->store($alice, $bob, $john);
+
+ $package = self::createPackage('vendor/package', 'https://github.com/vendor/package', maintainers: [$john, $alice]);
+ $this->store($package);
+
+ $result = $this->packageManager->transferPackage($package, [$bob, $alice]);
+
+ $em = self::getEM();
+ $em->flush();
+ $em->clear();
+
+ $this->assertTrue($result);
+
+ $callable = fn (User $user) => $user->getUsernameCanonical();
+ $this->assertEqualsCanonicalizing(['alice', 'bob'], array_map($callable, $package->getMaintainers()->toArray()));
+ $this->assertAuditLogWasCreated($package, ['john', 'alice'], ['bob', 'alice']);
+ }
+
+ public function testTransferPackageWithSameMaintainersDoesNothing(): void
+ {
+ $alice = self::createUser('alice', 'alice@example.org');
+ $bob = self::createUser('bob', 'bob@example.org');
+ $this->store($alice, $bob);
+
+ $package = self::createPackage('vendor/package', 'https://github.com/vendor/package', maintainers: [$bob, $alice]);
+ $this->store($package);
+
+ $result = $this->packageManager->transferPackage($package, [$alice, $bob]);
+
+ $em = self::getEM();
+ $em->flush();
+ $em->clear();
+
+ $this->assertFalse($result);
+
+ $record = $em->getRepository(AuditRecord::class)->findOneBy([
+ 'type' => AuditRecordType::PackageTransferred->value,
+ 'packageId' => $package->getId(),
+ ]);
+
+ $this->assertNull($record, 'No audit record should be created when maintainers are the same');
+ }
+
+ /**
+ * @param string[] $oldMaintainers
+ * @param string[] $newMaintainers
+ */
+ private function assertAuditLogWasCreated(Package $package, array $oldMaintainers, array $newMaintainers): void
+ {
+ $record = self::getEM()->getRepository(AuditRecord::class)->findOneBy([
+ 'type' => AuditRecordType::PackageTransferred->value,
+ 'packageId' => $package->getId(),
+ 'actorId' => null,
+ ]);
+
+ $this->assertNotNull($record, 'Audit record should be created for package transfer');
+ $this->assertSame($package->getId(), $record->packageId);
+
+ $callable = fn (array $user) => $user['username'];
+ $this->assertEqualsCanonicalizing($oldMaintainers, array_map($callable, $record->attributes['previous_maintainers']));
+ $this->assertEqualsCanonicalizing($newMaintainers, array_map($callable, $record->attributes['current_maintainers']));
+ }
}
From 85c911b7b57377149c88138ae7a75d10928e52f6 Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Fri, 14 Nov 2025 09:16:56 +0100
Subject: [PATCH 08/15] Clean-up #transfer-package-form CSS
---
css/app.scss | 20 +-------------------
1 file changed, 1 insertion(+), 19 deletions(-)
diff --git a/css/app.scss b/css/app.scss
index 02fddc488..fabc67e96 100644
--- a/css/app.scss
+++ b/css/app.scss
@@ -1893,15 +1893,9 @@ body {
.maintainers-collection-wrapper {
margin-bottom: 15px;
- label {
- font-weight: bold;
- margin-bottom: 10px;
- }
-
.maintainers-list {
list-style: none;
padding: 0;
- margin-bottom: 10px;
li {
display: flex;
@@ -1909,22 +1903,10 @@ body {
margin-bottom: 10px;
align-items: center;
- input[type="text"] {
- flex: 1;
- padding: 6px 12px;
- font-size: 14px;
- border: 1px solid #ccc;
- border-radius: 4px;
- }
-
.btn-danger {
- flex-shrink: 0;
+ padding: 5px 10px;
}
}
}
-
- .add-maintainer-item {
- margin-top: 5px;
- }
}
}
From 3ba81348288bfa68e76956debed951643edab5c9 Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Fri, 14 Nov 2025 09:54:54 +0100
Subject: [PATCH 09/15] Define array types with the `array` notation
---
src/Model/PackageManager.php | 5 ++---
tests/Model/PackageManagerTest.php | 4 ++--
2 files changed, 4 insertions(+), 5 deletions(-)
diff --git a/src/Model/PackageManager.php b/src/Model/PackageManager.php
index 4f5db1abd..457859452 100644
--- a/src/Model/PackageManager.php
+++ b/src/Model/PackageManager.php
@@ -241,9 +241,8 @@ public function notifyNewMaintainer(User $user, Package $package): bool
return true;
}
- /*
- * @param User[] $oldMaintainers
- * @param User[] $newMaintainers
+ /**
+ * @param array $newMaintainers
*/
public function transferPackage(Package $package, array $newMaintainers): bool
{
diff --git a/tests/Model/PackageManagerTest.php b/tests/Model/PackageManagerTest.php
index fd5f56eae..3b00c8b35 100644
--- a/tests/Model/PackageManagerTest.php
+++ b/tests/Model/PackageManagerTest.php
@@ -109,8 +109,8 @@ public function testTransferPackageWithSameMaintainersDoesNothing(): void
}
/**
- * @param string[] $oldMaintainers
- * @param string[] $newMaintainers
+ * @param array $oldMaintainers
+ * @param array $newMaintainers
*/
private function assertAuditLogWasCreated(Package $package, array $oldMaintainers, array $newMaintainers): void
{
From 046caa0f6104c40caf0586fd6711d112fd19efd9 Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Fri, 14 Nov 2025 17:44:06 +0100
Subject: [PATCH 10/15] Remove `href` attributes and fix form processing
---
src/Controller/PackageController.php | 42 ++++++++++++++----------
templates/package/view_package.html.twig | 8 ++---
2 files changed, 28 insertions(+), 22 deletions(-)
diff --git a/src/Controller/PackageController.php b/src/Controller/PackageController.php
index 85e9ed79d..8bdf50fed 100644
--- a/src/Controller/PackageController.php
+++ b/src/Controller/PackageController.php
@@ -998,28 +998,34 @@ public function transferPackageAction(Request $req, #[MapEntity] Package $packag
$form = $this->createTransferPackageForm($package);
$form->handleRequest($req);
- if ($form->isSubmitted() && $form->isValid()) {
- 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');
- }
+ if (!$form->isSubmitted()) {
+ return $this->redirectToRoute('view_package', ['name' => $package->getName()]);
+ }
- 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.');
- }
- } elseif (!$form->isValid()) {
+ 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()]);
diff --git a/templates/package/view_package.html.twig b/templates/package/view_package.html.twig
index 470d2e6e6..efa031020 100644
--- a/templates/package/view_package.html.twig
+++ b/templates/package/view_package.html.twig
@@ -145,12 +145,12 @@
{% endfor %}
-
+
{% if is_granted('remove_maintainer', package) or is_granted('add_maintainer', package) or is_granted('transfer_package', package) %}
Maintainer actions
- {% if is_granted('add_maintainer', package) %}{% endif %}
- {% if is_granted('remove_maintainer', package) %}{% endif %}
- {% if is_granted('transfer_package', package) %}{% endif %}
+ {% if is_granted('add_maintainer', package) %}{% endif %}
+ {% if is_granted('remove_maintainer', package) %}{% endif %}
+ {% if is_granted('transfer_package', package) %}{% endif %}
{% endif %}
Details
From bc1baed0ba20a13d5c070749fd77c412fcf33521 Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Tue, 9 Dec 2025 13:54:32 +0100
Subject: [PATCH 11/15] Clean-up CSS
---
css/app.scss | 19 +++++--------------
templates/package/view_package.html.twig | 13 +++++++++----
2 files changed, 14 insertions(+), 18 deletions(-)
diff --git a/css/app.scss b/css/app.scss
index fabc67e96..c144fcdc1 100644
--- a/css/app.scss
+++ b/css/app.scss
@@ -1082,20 +1082,11 @@ input:focus:invalid:focus, textarea:focus:invalid:focus, select:focus:invalid:fo
margin-bottom: 4px;
margin-right: 4px;
}
-.package .details #add-maintainer{
- margin-top: 3px;
- margin-bottom: 7px;
- font-size: 34px;
-}
-.package .details #transfer-package {
- margin-top: 7px;
- margin-bottom: 7px;
- margin-right: 4px;
- font-size: 34px;
-}
-.package .details #remove-maintainer {
- font-size: 35px;
- margin-top: 5px;
+.package .details .maintainer-actions {
+ a {
+ font-size: 34px;
+ cursor: pointer;
+ }
}
.package .details .canonical,
.package .funding p {
diff --git a/templates/package/view_package.html.twig b/templates/package/view_package.html.twig
index efa031020..92fa99d38 100644
--- a/templates/package/view_package.html.twig
+++ b/templates/package/view_package.html.twig
@@ -148,9 +148,11 @@
{% if is_granted('remove_maintainer', package) or is_granted('add_maintainer', package) or is_granted('transfer_package', package) %}
Maintainer actions
- {% if is_granted('add_maintainer', package) %}{% endif %}
- {% if is_granted('remove_maintainer', package) %}{% endif %}
- {% if is_granted('transfer_package', package) %}{% endif %}
+
+ {% if is_granted('add_maintainer', package) %}{% endif %}
+ {% if is_granted('remove_maintainer', package) %}{% endif %}
+ {% if is_granted('transfer_package', package) %}{% endif %}
+
{% endif %}
Details
@@ -327,7 +329,10 @@
{% if is_granted('transfer_package', package) %}
{{ form_start(transferPackageForm, {
- attr: { id: 'transfer-package-form', class: 'col-sm-6 col-md-3 col-md-offset-9 ' ~ (show_transfer_package_form|default(false) ? '': 'hidden') },
+ attr: {
+ id: 'transfer-package-form',
+ class: 'col-sm-6 col-md-3 col-md-offset-9 ' ~ (show_transfer_package_form|default(false) ? '': 'hidden'),
+ },
action: path('transfer_package', { 'name': package.name })
}) }}
From 67d982431c97b548e634b5d8feab2526ffca0cb2 Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Tue, 9 Dec 2025 14:17:44 +0100
Subject: [PATCH 12/15] Exclude users that are disabled
---
src/Command/TransferOwnershipCommand.php | 2 +-
src/Entity/UserRepository.php | 7 +++++--
src/Form/Type/MaintainerType.php | 9 +++++++++
tests/Command/TransferOwnershipCommandTest.php | 16 ++++++++++++++++
tests/Controller/PackageControllerTest.php | 4 +++-
tests/Entity/UserRepositoryTest.php | 5 +++--
tests/IntegrationTestCase.php | 2 +-
7 files changed, 38 insertions(+), 7 deletions(-)
diff --git a/src/Command/TransferOwnershipCommand.php b/src/Command/TransferOwnershipCommand.php
index b31125ce0..b2632097e 100644
--- a/src/Command/TransferOwnershipCommand.php
+++ b/src/Command/TransferOwnershipCommand.php
@@ -89,7 +89,7 @@ private function queryAndValidateMaintainers(InputInterface $input, OutputInterf
$usernames = array_map('mb_strtolower', $input->getArgument('maintainers'));
sort($usernames);
- $maintainers = $this->getEM()->getRepository(User::class)->findUsersByUsername($usernames, ['usernameCanonical' => 'ASC']);
+ $maintainers = $this->getEM()->getRepository(User::class)->findEnabledUsersByUsername($usernames, ['usernameCanonical' => 'ASC']);
if (array_keys($maintainers) === $usernames) {
return $maintainers;
diff --git a/src/Entity/UserRepository.php b/src/Entity/UserRepository.php
index 028d8a525..352d851c9 100644
--- a/src/Entity/UserRepository.php
+++ b/src/Entity/UserRepository.php
@@ -46,9 +46,12 @@ public function findOneByUsernameOrEmail(string $usernameOrEmail): ?User
* @param ?array $orderBy
* @return array
*/
- public function findUsersByUsername(array $usernames, ?array $orderBy = null): array
+ public function findEnabledUsersByUsername(array $usernames, ?array $orderBy = null): array
{
- $matches = $this->findBy(['usernameCanonical' => $usernames], $orderBy);
+ $matches = $this->findBy([
+ 'usernameCanonical' => $usernames,
+ 'enabled' => true,
+ ], $orderBy);
$users = [];
foreach ($matches as $match) {
diff --git a/src/Form/Type/MaintainerType.php b/src/Form/Type/MaintainerType.php
index 930c9457f..db1bd8dda 100644
--- a/src/Form/Type/MaintainerType.php
+++ b/src/Form/Type/MaintainerType.php
@@ -53,6 +53,15 @@ function (?string $username): ?User {
throw $failure;
}
+ if (!$user->isEnabled()) {
+ $failure = new TransformationFailedException(sprintf('User "%s" is disabled.', $username));
+ $failure->setInvalidMessage('The user "{{ value }}" is disabled.', [
+ '{{ value }}' => $username,
+ ]);
+
+ throw $failure;
+ }
+
return $user;
}
));
diff --git a/tests/Command/TransferOwnershipCommandTest.php b/tests/Command/TransferOwnershipCommandTest.php
index d21946a80..deaefa774 100644
--- a/tests/Command/TransferOwnershipCommandTest.php
+++ b/tests/Command/TransferOwnershipCommandTest.php
@@ -132,6 +132,22 @@ public function testExecuteFailsWithUnknownMaintainers(): void
$this->assertStringContainsString('2 maintainers could not be found', $output);
}
+ public function testExecuteFailsWithDisabledMaintainers(): void
+ {
+ $tom = self::createUser('tom', 'tom@example.org', enabled: false);
+ $this->store($tom);
+
+ $this->commandTester->execute([
+ 'vendorOrPackage' => 'vendor1',
+ 'maintainers' => ['alice', 'tom'],
+ ]);
+
+ $this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
+
+ $output = $this->commandTester->getDisplay();
+ $this->assertStringContainsString('1 maintainers could not be found', $output);
+ }
+
public function testExecuteFailsIfNoVendorPackagesFound(): void
{
$this->commandTester->execute([
diff --git a/tests/Controller/PackageControllerTest.php b/tests/Controller/PackageControllerTest.php
index b44d064d2..7e78c1c40 100644
--- a/tests/Controller/PackageControllerTest.php
+++ b/tests/Controller/PackageControllerTest.php
@@ -196,12 +196,14 @@ public function testTransferPackage(): void
#[TestWith(['does_not_exist', 'value is not a valid username or email'])]
#[TestWith([null, 'at least one maintainer must be specified'])]
+ #[TestWith(['bob', 'The user "bob" is disabled'])]
public function testTransferPackageReturnsValidationError(?string $value, string $message): void
{
$alice = self::createUser('alice', 'alice@example.org');
+ $bob = self::createUser('bob', 'bob@example.org', enabled: false);
$package = self::createPackage('test/pkg', 'https://example.com/test/pkg', maintainers: [$alice]);
- $this->store($alice, $package);
+ $this->store($alice, $bob, $package);
$this->client->loginUser($alice);
diff --git a/tests/Entity/UserRepositoryTest.php b/tests/Entity/UserRepositoryTest.php
index 2e32eee7d..32119d6b2 100644
--- a/tests/Entity/UserRepositoryTest.php
+++ b/tests/Entity/UserRepositoryTest.php
@@ -31,9 +31,10 @@ public function testFindUsersByUsernameWithMultipleValidUsernames(): void
$alice = self::createUser('Alice', 'alice@example.org');
$bob = self::createUser('Bob', 'bob@example.org');
$charlie = self::createUser('Charlie', 'charlie@example.org');
- $this->store($alice, $bob, $charlie);
+ $john = self::createUser('John', 'john@example.org', enabled: false);
+ $this->store($alice, $bob, $charlie, $john);
- $result = $this->userRepository->findUsersByUsername(['alice', 'bob']);
+ $result = $this->userRepository->findEnabledUsersByUsername(['alice', 'bob', 'john']);
$this->assertCount(2, $result);
diff --git a/tests/IntegrationTestCase.php b/tests/IntegrationTestCase.php
index 05fd5eb1d..e80bfe9eb 100644
--- a/tests/IntegrationTestCase.php
+++ b/tests/IntegrationTestCase.php
@@ -104,7 +104,7 @@ protected static function createPackage(string $name, string $repository, ?strin
protected static function createUser(string $username = 'test', string $email = 'test@example.org', string $password = 'testtest', string $apiToken = 'api-token', string $safeApiToken = 'safe-api-token', string $githubId = '12345', bool $enabled = true, array $roles = []): User
{
$user = new User();
- $user->setEnabled(true);
+ $user->setEnabled($enabled);
$user->setUsername($username);
$user->setEmail($email);
$user->setPassword($password);
From f3694d767bfd76fc41ff25ece69cc6eb8311d7d0 Mon Sep 17 00:00:00 2001
From: Jordi Boggiano
Date: Wed, 10 Dec 2025 16:40:40 +0100
Subject: [PATCH 13/15] Minor tweaks/cleanups
---
src/Controller/PackageController.php | 5 ++--
src/Form/Model/TransferPackageRequest.php | 28 ++++-------------------
2 files changed, 7 insertions(+), 26 deletions(-)
diff --git a/src/Controller/PackageController.php b/src/Controller/PackageController.php
index 8bdf50fed..d6f382379 100644
--- a/src/Controller/PackageController.php
+++ b/src/Controller/PackageController.php
@@ -990,7 +990,7 @@ public function removeMaintainerAction(Request $req, #[MapEntity] Package $packa
}
- #[Route(path: '/packages/{name:package}/transfer/', name: 'transfer_package', requirements: ['name' => Package::PACKAGE_NAME_REGEX])]
+ #[Route(path: '/packages/{name:package}/transfer/', name: 'transfer_package', requirements: ['name' => Package::PACKAGE_NAME_REGEX], methods: ['GET', 'POST'])]
public function transferPackageAction(Request $req, #[MapEntity] Package $package, #[CurrentUser] User $user, LoggerInterface $logger): RedirectResponse
{
$this->denyAccessUnlessGranted(PackageActions::TransferPackage->value, $package);
@@ -1673,8 +1673,7 @@ private function createRemoveMaintainerForm(Package $package): FormInterface
*/
private function createTransferPackageForm(Package $package): FormInterface
{
- $transferRequest = new TransferPackageRequest();
- $transferRequest->setMaintainers(clone $package->getMaintainers());
+ $transferRequest = new TransferPackageRequest(clone $package->getMaintainers());
return $this->createForm(TransferPackageRequestType::class, $transferRequest);
}
diff --git a/src/Form/Model/TransferPackageRequest.php b/src/Form/Model/TransferPackageRequest.php
index 747debb94..71fb028ee 100644
--- a/src/Form/Model/TransferPackageRequest.php
+++ b/src/Form/Model/TransferPackageRequest.php
@@ -19,28 +19,10 @@
class TransferPackageRequest
{
- /** @var Collection */
- #[TransferPackageValidMaintainersList]
- private Collection $maintainers;
-
- public function __construct()
- {
- $this->maintainers = new ArrayCollection();
- }
-
- /**
- * @return Collection
- */
- public function getMaintainers(): Collection
- {
- return $this->maintainers;
- }
-
- /**
- * @param Collection $maintainers
- */
- public function setMaintainers(Collection $maintainers): void
- {
- $this->maintainers = $maintainers;
+ public function __construct(
+ /** @var Collection */
+ #[TransferPackageValidMaintainersList]
+ public Collection $maintainers = new ArrayCollection(),
+ ) {
}
}
From 844ad18a5010fb9fa0ace5697ef32f515f775021 Mon Sep 17 00:00:00 2001
From: Jordi Boggiano
Date: Wed, 10 Dec 2025 17:01:25 +0100
Subject: [PATCH 14/15] Fix usage, make regex more possessive to avoid router
issues with package names containing underscores
---
src/Controller/PackageController.php | 2 +-
src/Entity/Package.php | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/Controller/PackageController.php b/src/Controller/PackageController.php
index d6f382379..db9cc3993 100644
--- a/src/Controller/PackageController.php
+++ b/src/Controller/PackageController.php
@@ -1011,7 +1011,7 @@ public function transferPackageAction(Request $req, #[MapEntity] Package $packag
}
try {
- $newMaintainers = $form->getData()->getMaintainers()->toArray();
+ $newMaintainers = $form->getData()->maintainers->toArray();
$result = $this->packageManager->transferPackage($package, $newMaintainers);
$this->getEM()->flush();
diff --git a/src/Entity/Package.php b/src/Entity/Package.php
index d4a5c78b4..c96c27c0e 100644
--- a/src/Entity/Package.php
+++ b/src/Entity/Package.php
@@ -90,7 +90,7 @@ class Package
public const AUTO_MANUAL_HOOK = 1;
public const AUTO_GITHUB_HOOK = 2;
- public const string PACKAGE_NAME_REGEX = '[a-zA-Z0-9](?:[_.-]?[a-zA-Z0-9]+)*/[a-zA-Z0-9](?:[_.-]?[a-zA-Z0-9]+)*';
+ public const string PACKAGE_NAME_REGEX = '[a-zA-Z0-9]++(?:[_.-]?[a-zA-Z0-9]++)*+/[a-zA-Z0-9]++(?:[_.-]?[a-zA-Z0-9]++)*+';
#[ORM\Id]
#[ORM\Column(type: 'integer')]
From 36229601d69d61da98267596a558c3c60b4f89df Mon Sep 17 00:00:00 2001
From: Steven Rombauts
Date: Wed, 10 Dec 2025 17:41:23 +0100
Subject: [PATCH 15/15] Reuse `findEnabledUsersByUsername()` in
`MaintainerType` too
---
src/Form/Type/MaintainerType.php | 18 +++++-------------
tests/Controller/PackageControllerTest.php | 5 ++---
2 files changed, 7 insertions(+), 16 deletions(-)
diff --git a/src/Form/Type/MaintainerType.php b/src/Form/Type/MaintainerType.php
index db1bd8dda..9a7cbe279 100644
--- a/src/Form/Type/MaintainerType.php
+++ b/src/Form/Type/MaintainerType.php
@@ -42,27 +42,19 @@ function (?string $username): ?User {
return null;
}
- $user = $this->em->getRepository(User::class)->findOneByUsernameOrEmail($username);
+ $username = mb_strtolower($username);
+ $users = $this->em->getRepository(User::class)->findEnabledUsersByUsername([$username]);
- if ($user === null) {
+ if (!count($users) || !array_key_exists($username, $users)) {
$failure = new TransformationFailedException(sprintf('User "%s" does not exist.', $username));
- $failure->setInvalidMessage('The given "{{ value }}" value is not a valid username or email.', [
+ $failure->setInvalidMessage('The given "{{ value }}" value is not a valid username.', [
'{{ value }}' => $username,
]);
throw $failure;
}
- if (!$user->isEnabled()) {
- $failure = new TransformationFailedException(sprintf('User "%s" is disabled.', $username));
- $failure->setInvalidMessage('The user "{{ value }}" is disabled.', [
- '{{ value }}' => $username,
- ]);
-
- throw $failure;
- }
-
- return $user;
+ return $users[$username];
}
));
}
diff --git a/tests/Controller/PackageControllerTest.php b/tests/Controller/PackageControllerTest.php
index 7e78c1c40..ae44aa960 100644
--- a/tests/Controller/PackageControllerTest.php
+++ b/tests/Controller/PackageControllerTest.php
@@ -166,7 +166,7 @@ public function testTransferPackage(): void
$form = $crawler->filter('[name="transfer_package_form"]')->form();
$form->setValues([
'transfer_package_form[maintainers][0]' => 'alice',
- 'transfer_package_form[maintainers][1]' => 'bob@example.org',
+ 'transfer_package_form[maintainers][1]' => 'bob',
]);
$this->client->submit($form);
@@ -194,9 +194,8 @@ public function testTransferPackage(): void
$this->assertNotNull($auditRecord, 'Audit record not found');
}
- #[TestWith(['does_not_exist', 'value is not a valid username or email'])]
+ #[TestWith(['does_not_exist', 'value is not a valid username'])]
#[TestWith([null, 'at least one maintainer must be specified'])]
- #[TestWith(['bob', 'The user "bob" is disabled'])]
public function testTransferPackageReturnsValidationError(?string $value, string $message): void
{
$alice = self::createUser('alice', 'alice@example.org');