From 55382f115c04d0bfc5bc3dad456a1afeac8f484e Mon Sep 17 00:00:00 2001 From: Uladzimir Tsykun Date: Fri, 8 Nov 2024 00:58:04 +0100 Subject: [PATCH] Allow public access to some packages via a sub-repository --- src/Controller/ZipballController.php | 6 +++++ src/Entity/SubRepository.php | 14 ++++++++++ src/EventListener/ProtectHostListener.php | 5 ++-- src/Form/Type/SubRepositoryType.php | 5 ++++ src/Model/PatUserScores.php | 2 +- src/Package/InMemoryDumper.php | 2 +- src/Repository/SubEntityRepository.php | 2 +- src/Security/Acl/SubRepoGrantVoter.php | 31 ++++++++++++++++++++--- src/Service/SubRepositoryHelper.php | 10 +++++--- 9 files changed, 66 insertions(+), 11 deletions(-) diff --git a/src/Controller/ZipballController.php b/src/Controller/ZipballController.php index 72be2d84..4c6d2e0e 100644 --- a/src/Controller/ZipballController.php +++ b/src/Controller/ZipballController.php @@ -85,6 +85,12 @@ public function zipballList(Request $request): Response requirements: ['package' => '%package_name_regex%', 'hash' => '[a-f0-9]{40}(\.?[A-Za-z\.]+?)?'], methods: ['GET'] )] + #[Route( + '/{slug}/zipball/{package}/{hash}', + name: 'download_dist_package_slug', + requirements: ['package' => '%package_name_regex%', 'hash' => '[a-f0-9]{40}(\.?[A-Za-z\.]+?)?'], + methods: ['GET'] + )] public function zipballAction(#[Vars('name')] Package $package, string $hash): Response { if ((false === $this->dm->isEnabled() && false === RepTypes::isBuildInDist($package->getRepoType())) diff --git a/src/Entity/SubRepository.php b/src/Entity/SubRepository.php index e7177217..a4dab603 100644 --- a/src/Entity/SubRepository.php +++ b/src/Entity/SubRepository.php @@ -32,6 +32,9 @@ class SubRepository #[ORM\Column(type: 'json', nullable: true)] private ?array $packages = null; + #[ORM\Column(name: 'public_access', type: 'boolean', nullable: true)] + private ?bool $publicAccess = null; + /** @internal */ private ?array $cachedIds = null; @@ -129,4 +132,15 @@ public function setCachedIds(?array $cachedIds): static $this->cachedIds = $cachedIds; return $this; } + + public function isPublicAccess(): ?bool + { + return $this->publicAccess; + } + + public function setPublicAccess(?bool $publicAccess): static + { + $this->publicAccess = $publicAccess; + return $this; + } } diff --git a/src/EventListener/ProtectHostListener.php b/src/EventListener/ProtectHostListener.php index 0a5fa6f4..72ec40a1 100644 --- a/src/EventListener/ProtectHostListener.php +++ b/src/EventListener/ProtectHostListener.php @@ -21,11 +21,12 @@ class ProtectHostListener 'root_package_v2' => 1, 'download_dist_package' => 1, 'track_download' => 1, - 'track_download_batch' =>1, - 'root_packages_slug' =>1, + 'track_download_batch' => 1, + 'root_packages_slug' => 1, 'root_providers_slug' => 1, 'root_package_slug' => 1, 'root_package_v2_slug' => 1, + 'download_dist_package_slug' => 1, 'mirror_root' => 1, 'mirror_metadata_v2' => 1, 'mirror_metadata_v1' => 1, diff --git a/src/Form/Type/SubRepositoryType.php b/src/Form/Type/SubRepositoryType.php index 7b1b0035..f34ea306 100644 --- a/src/Form/Type/SubRepositoryType.php +++ b/src/Form/Type/SubRepositoryType.php @@ -8,6 +8,7 @@ use Packeton\Entity\Package; use Packeton\Entity\SubRepository; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -52,6 +53,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'Subdomain or separate hostname', 'attr' => ['placeholder' => "e.g.: repo1.example.com\nrepo2.example.com", 'rows' => 4] ]) + ->add('publicAccess', CheckboxType::class, [ + 'required' => false, + 'label' => 'Allow public access', + ]) ->add('packages', ChoiceType::class, [ 'required' => false, 'multiple' => true, diff --git a/src/Model/PatUserScores.php b/src/Model/PatUserScores.php index 4b77f606..a8e2cfbb 100644 --- a/src/Model/PatUserScores.php +++ b/src/Model/PatUserScores.php @@ -10,7 +10,7 @@ class PatUserScores 'metadata' => [ 'root_packages', 'root_providers', 'metadata_changes', 'root_package', 'root_package_v2', 'download_dist_package', 'track_download', 'track_download_batch', - 'root_packages_slug', 'root_providers_slug', 'root_package_slug', 'root_package_v2_slug', + 'root_packages_slug', 'root_providers_slug', 'root_package_slug', 'root_package_v2_slug', 'download_dist_package_slug' ], 'mirror:read' => ['mirror_root', 'mirror_metadata_v2', 'mirror_metadata_v1', 'mirror_zipball', 'mirror_provider_includes'], 'mirror:all' => ['@mirror:read'], diff --git a/src/Package/InMemoryDumper.php b/src/Package/InMemoryDumper.php index c738f5ca..bca1e02f 100644 --- a/src/Package/InMemoryDumper.php +++ b/src/Package/InMemoryDumper.php @@ -107,7 +107,7 @@ private function dumpRootPackages(?UserInterface $user = null, ?int $apiVersion if ($this->distConfig->mirrorEnabled()) { $ref = '0000000000000000000000000000000000000000.zip'; - $zipball = $this->router->generate('download_dist_package', ['package' => 'VND/PKG', 'hash' => $ref]); + $zipball = $slug . $this->router->generate('download_dist_package', ['package' => 'VND/PKG', 'hash' => $ref]); $rootFile['mirrors'][] = ['dist-url' => \str_replace(['VND/PKG', $ref], ['%package%', '%reference%.%type%'], $zipball), 'preferred' => true]; } diff --git a/src/Repository/SubEntityRepository.php b/src/Repository/SubEntityRepository.php index fa361d6f..a38d37d1 100644 --- a/src/Repository/SubEntityRepository.php +++ b/src/Repository/SubEntityRepository.php @@ -11,7 +11,7 @@ public function getSubRepositoryData(): array { $data = $this->createQueryBuilder('s') ->resetDQLPart('select') - ->select(['s.id', 's.slug', 's.urls', 's.name']) + ->select(['s.id', 's.slug', 's.urls', 's.name', 's.publicAccess as public']) ->getQuery() ->getArrayResult(); diff --git a/src/Security/Acl/SubRepoGrantVoter.php b/src/Security/Acl/SubRepoGrantVoter.php index d217a5c6..2f071dc6 100644 --- a/src/Security/Acl/SubRepoGrantVoter.php +++ b/src/Security/Acl/SubRepoGrantVoter.php @@ -4,6 +4,7 @@ namespace Packeton\Security\Acl; +use Packeton\Service\SubRepositoryHelper; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\CacheableVoterInterface; @@ -15,15 +16,21 @@ class SubRepoGrantVoter implements CacheableVoterInterface 'root_providers_slug' => 1, 'root_package_slug' => 1, 'root_package_v2_slug' => 1, + 'download_dist_package_slug' => 1, ]; + public function __construct( + private readonly SubRepositoryHelper $helper + ) { + } + /** * {@inheritdoc} */ - public function vote(TokenInterface $token, mixed $subject, array $attributes): int + public function vote(TokenInterface $token, mixed $request, array $attributes): int { - if ($subject instanceof Request && isset(self::$subRoutes[$subject->attributes->get('_route')])) { - return $token->getUser() ? self::ACCESS_GRANTED : self::ACCESS_ABSTAIN; + if ($request instanceof Request && isset(self::$subRoutes[$request->attributes->get('_route')])) { + return $token->getUser() || $this->isPublicSubRepo($request) ? self::ACCESS_GRANTED : self::ACCESS_ABSTAIN; } return self::ACCESS_ABSTAIN; @@ -44,4 +51,22 @@ public function supportsType(string $subjectType): bool { return $subjectType === Request::class; } + + private function isPublicSubRepo(Request $request): bool + { + if (null !== ($subRepo = $this->getSubRepoForRequest($request))) { + return $this->helper->isPublicAccess($subRepo); + } + return false; + } + + private function getSubRepoForRequest(Request $request): ?int + { + $route = (string) $request->attributes->get('_route'); + if ($request->attributes->has('slug') && (SubRepoGrantVoter::$subRoutes[$route] ?? null)) { + return $this->helper->getBySlug($request->attributes->get('slug')); + } + + return $this->helper->getByHost($request->getHost()); + } } diff --git a/src/Service/SubRepositoryHelper.php b/src/Service/SubRepositoryHelper.php index b077ade8..503baf22 100644 --- a/src/Service/SubRepositoryHelper.php +++ b/src/Service/SubRepositoryHelper.php @@ -90,6 +90,12 @@ public function isAutoHost(): bool return $req->attributes->get('_sub_repo_type') === SubRepository::AUTO_HOST; } + public function isPublicAccess(?int $subRepo = null): bool + { + $subRepo ??= $this->getSubrepositoryId(); + return $this->getData()[$subRepo]['public'] ?? false; + } + public static function applyCondition(QueryBuilder $qb, ?array $allowed): QueryBuilder { if ($allowed === null) { @@ -161,8 +167,6 @@ public function getTwigData(?UserInterface $user = null): array protected function getData(): array { - return $this->cache->get('sub_repos_list', function () { - return $this->registry->getRepository(SubRepository::class)->getSubRepositoryData(); - }); + return $this->cache->get('sub_repos_list', fn () => $this->registry->getRepository(SubRepository::class)->getSubRepositoryData()); } }