Skip to content

Commit

Permalink
GitLab's integration webhooks support
Browse files Browse the repository at this point in the history
  • Loading branch information
vtsykun committed Jun 4, 2023
1 parent 7b53797 commit 17fab5f
Show file tree
Hide file tree
Showing 40 changed files with 718 additions and 70 deletions.
2 changes: 1 addition & 1 deletion config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ security:
# Maintainers
- { path: (^(/users/(.+)/packages))+, roles: ROLE_MAINTAINER }
- { path: (^(/users/(.+)/favorites))+, roles: ROLE_MAINTAINER }
- { path: (^(/metadata/changes.json$|/explore|/jobs/|/archive/))+, roles: ROLE_MAINTAINER }
- { path: (^(/metadata/changes.json$|/explore|/jobs/|/archive/|/api/hooks/))+, roles: ROLE_MAINTAINER }

# Secured part of the site
# This config requires being logged for the whole site and having the admin role for the admin part.
Expand Down
3 changes: 2 additions & 1 deletion config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ services:
- '@Packeton\Security\Token\DefaultTokenChecker'
calls:
- [addTokenChecker, ['@Packeton\Security\Token\PatTokenChecker']]
- [addTokenChecker, ['@Packeton\Security\Token\PatTokenChecker']]
- [addTokenChecker, ['@Packeton\Security\Token\JwtTokenChecker']]
- [addTokenChecker, ['@Packeton\Security\Token\IntegrationTokenChecker']]

Symfony\Component\HttpClient\NoPrivateNetworkHttpClient: ~
File renamed without changes.
13 changes: 9 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,21 @@ services:
build:
context: .
image: packeton/packeton:latest
restart: unless-stopped
container_name: packagist
hostname: packagist
environment:
ADMIN_USER: admin
ADMIN_PASSWORD: 123456
ADMIN_EMAIL: [email protected]
# ADMIN_USER: admin
# ADMIN_PASSWORD: 123456
# ADMIN_EMAIL: [email protected]
TRUSTED_PROXIES: 172.16.0.0/12
# Default SQLite
# DATABASE_URL: "mysql://app:[email protected]:3306/app?serverVersion=8&charset=utf8mb4"
DATABASE_URL: "mysql://app:[email protected]:3306/app?serverVersion=8&charset=utf8mb4"

# MAILER_DSN: smtp://user:[email protected]:587
# MAILER_FROM: Packeton <[email protected]>
ports:
# setup nginx reverse proxy for ssl
- '127.0.0.1:8088:80'
volumes:
- .docker:/data
Binary file added public/packeton/img/logo/bitbucket.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/packeton/img/logo/gitea.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/packeton/img/logo/github.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/packeton/img/logo/gitlab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions public/packeton/js/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,9 @@
if (typeof select2.select2 === 'function') {
select2.select2();
}

let tooltip = $('[data-toggle="tooltip"]');
if (typeof tooltip.tooltip === 'function') {
tooltip.tooltip();
}
})(jQuery, humane);
48 changes: 44 additions & 4 deletions src/Controller/Api/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
use Packeton\Entity\Package;
use Packeton\Entity\User;
use Packeton\Entity\Webhook;
use Packeton\Integrations\IntegrationRegistry;
use Packeton\Model\AutoHookUser;
use Packeton\Model\DownloadManager;
use Packeton\Model\PackageManager;
use Packeton\Service\Scheduler;
Expand All @@ -35,7 +37,9 @@ public function __construct(
protected DownloadManager $downloadManager,
protected LoggerInterface $logger,
protected ValidatorInterface $validator,
){}
protected IntegrationRegistry $integrations
) {
}

#[Route('/api/create-package', name: 'generic_create', methods: ['POST'])]
public function createPackageAction(Request $request): Response
Expand Down Expand Up @@ -65,6 +69,8 @@ public function createPackageAction(Request $request): Response
$em = $this->registry->getManager();
$em->persist($package);
$em->flush();

$this->container->get(PackageManager::class)->insetPackage($package);
} catch (\Exception $e) {
$this->logger->critical($e->getMessage(), ['exception', $e]);
return new JsonResponse(['status' => 'error', 'message' => 'Error saving package'], 500);
Expand All @@ -78,15 +84,23 @@ public function createPackageAction(Request $request): Response
#[Route('/api/hooks/{alias}/{id}', name: 'api_integration_postreceive')]
public function integrationHook(Request $request, string $alias, #[Vars] OAuthIntegration $oauth): Response
{
// parse the payload
$payload = $this->getJsonPayload($request);
if ($alias !== $oauth->getAlias()) {
return new JsonResponse(['error' => "App $alias is not found"], 409);
}

$response = $this->receiveIntegrationHook($request, $oauth);
if (null !== $response) {
return new JsonResponse($response, $response['code'] ?? 200);
}

return $this->updatePackageAction($request, fallback: true);
}

#[Route('/api/github', name: 'github_postreceive')]
#[Route('/api/bitbucket', name: 'bitbucket_postreceive')]
#[Route('/api/update-package', name: 'generic_postreceive')]
#[Route('/api/update-package/{name}', name: 'generic_named_postreceive', requirements: ['name' => '%package_name_regex%'])]
public function updatePackageAction(Request $request, #[Vars] Package $package = null): Response
public function updatePackageAction(Request $request, #[Vars] Package $package = null, bool $fallback = false): Response
{
// parse the payload
$payload = $this->getJsonPayload($request);
Expand All @@ -95,6 +109,12 @@ public function updatePackageAction(Request $request, #[Vars] Package $package =
return new JsonResponse(['status' => 'error', 'message' => 'Missing payload parameter'], 406);
}

// May helpfully for GitLab Packagist Integrations. Replacement for group webhooks that enabled only for PAID EE version
// See docs how to use GitLab Integrations
if (false === $fallback && $this->getUser() instanceof AutoHookUser && null !== ($response = $this->receiveIntegrationHook($request))) {
return new JsonResponse($response, $response['code'] ?? 200);
}

$packages = [$package];
// Get from query parameter.
if ($packageNames = $request->get('composer_package_name')) {
Expand Down Expand Up @@ -332,6 +352,26 @@ protected function receivePost(Request $request, $url, $urlRegex)
return $this->schedulePostJobs($packages);
}

protected function receiveIntegrationHook(Request $request, OAuthIntegration $oauth = null): ?array
{
$user = $this->getUser();
if (null === $oauth && $user instanceof AutoHookUser) {
$oauth = $this->registry->getRepository(OAuthIntegration::class)->find((int) $user->getHookIdentifier());
if (null === $oauth) {
return null;
}
}

try {
$app = $this->integrations->findApp($oauth->getAlias());
return $app->receiveHooks($request, $this->getJsonPayload($request), $oauth);
} catch (\Throwable $e) {
$this->logger->error($e->getMessage(), ['e' => $e]);
}

return null;
}

/**
* @param Package[] $packages
* @return Response
Expand Down
25 changes: 21 additions & 4 deletions src/Controller/OAuth/IntegrationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
use Packeton\Integrations\Exception\NotFoundAppException;
use Packeton\Integrations\IntegrationRegistry;
use Packeton\Integrations\AppInterface;
use Packeton\Integrations\Model\IntegrationUtils;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
Expand All @@ -23,13 +25,22 @@ class IntegrationController extends AbstractController
public function __construct(
protected IntegrationRegistry $integrations,
protected ManagerRegistry $registry,
protected LoggerInterface $logger
) {
}

#[Route('', name: 'integration_list')]
public function listAction(): Response
{
$integrations = $this->integrations->findAllApps();
return $this->render('integration/list.html.twig', ['integrations' => $integrations]);
}

#[Route('/connect', name: 'integration_connect')]
public function connect(): Response
{
$integrations = $this->integrations->findAllApps();
return $this->render('integration/connect.html.twig', ['integrations' => $integrations]);
}

#[Route('/{alias}/{id}', name: 'integration_index')]
Expand Down Expand Up @@ -100,16 +111,22 @@ public function connectOrg(Request $request, string $alias, #[Vars] OAuthIntegra

$client = $this->getClient($alias, $oauth);
$oauth->setConnected($org, $connected = !$oauth->isConnected($org));
$this->registry->getManager()->flush();


$response = ['connected' => $connected];
try {
$connected ? $client->addOrgHook($oauth, $org) : $client->removeOrgHook($oauth, $org);
$status = $connected ? $client->addOrgHook($oauth, $org) : $client->removeOrgHook($oauth, $org);
if (is_array($status)) {
$oauth->setWebhookInfo($org, $status);
$response += $status;
}
} catch (\Throwable $e) {
return new JsonResponse(['error' => $e->getMessage()] + $response, 400);
$this->logger->error($e->getMessage(), ['e' => $e]);
$response += ['error' => IntegrationUtils::castError($e), 'code' => 409];
}

return new JsonResponse($response, 200);
$this->registry->getManager()->flush();
return new JsonResponse($response, $response['code'] ?? 200);
}

protected function getClient($alias, OAuthIntegration $oauth = null): AppInterface
Expand Down
2 changes: 1 addition & 1 deletion src/Controller/PackageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ public function submitPackageAction(Request $req, string $type = null): Response
$em->persist($package);
$em->flush();

$this->providerManager->insertPackage($package);
$this->container->get(PackageManager::class)->insetPackage($package);
$this->addFlash('success', $package->getName().' has been added to the package list, the repository will now be crawled.');

return new RedirectResponse($this->generateUrl('view_package', ['name' => $package->getName()]));
Expand Down
15 changes: 13 additions & 2 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ private function addIntegrationSection(ArrayNodeDefinition $rootNode, array $fac
$nodeBuilder->children()
->booleanNode('enabled')->defaultTrue()->end()
->scalarNode('base_url')->end()
->scalarNode('webhook_url')->info('Static current host')->end()
->scalarNode('svg_logo')->end()
->scalarNode('logo')->end()
->scalarNode('login_title')->end()
Expand Down Expand Up @@ -267,15 +268,25 @@ private function defaultIconsData(): array
{
return [
'github' => [
'logo' => null,
'logo' => '/packeton/img/logo/github.png',
'svg_logo' => 'svg/github.html.twig',
'login_title' => 'Login with GitHub',
],
'gitlab' => [
'logo' => null,
'logo' => '/packeton/img/logo/gitlab.png',
'svg_logo' => 'svg/gitlab.html.twig',
'login_title' => 'Login with GitLab',
],
'gitea' => [
'logo' => '/packeton/img/logo/gitea.png',
'svg_logo' => 'svg/gitea.html.twig',
'login_title' => 'Login with Gitea',
],
'bitbucket' => [
'logo' => '/packeton/img/logo/bitbucket.png',
'svg_logo' => 'svg/gitea.html.twig',
'login_title' => 'Login with Bitbucket',
]
];
}
}
12 changes: 12 additions & 0 deletions src/Entity/OAuthIntegration.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ public function getEnabledOrganizations(): array
return $this->getSerialized('enabled_org', 'array', []);
}

public function setWebhookInfo(string|int $orgs, array $info): self
{
$info += ['status' => true, 'error' => null, 'id' => null];
return $this->setSerialized("web_hook_$orgs", $info);
}

public function getWebhookInfo(string|int $orgs): ?array
{
$info = $this->getSerialized("web_hook_$orgs", 'array');
return is_array($info) ? $info + ['status' => null, 'error' => null, 'id' => null] : null;
}

public function isConnected(string|int $name): bool
{
return in_array((string)$name, $this->getEnabledOrganizations());
Expand Down
10 changes: 10 additions & 0 deletions src/Entity/PackageSerializedTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ public function setExcludedGlob(?string $glob): void
$this->setSerializedField('excludedGlob', $glob);
}

public function getWebhookInfo(): ?array
{
return $this->serializedData['webhook_info'] ?? null;
}

public function setWebhookInfo(?array $info): void
{
$this->setSerializedField('webhook_info', $info);
}

public function isSkipNotModifyTag(): ?bool
{
return (bool)($this->serializedData['skip_empty_tag'] ?? null);
Expand Down
22 changes: 22 additions & 0 deletions src/Event/PackageEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Packeton\Event;

use Packeton\Entity\Package;
use Symfony\Contracts\EventDispatcher\Event;

class PackageEvent extends Event
{
public const PACKAGE_CREATE = 'packageCreate';

public function __construct(protected Package $package)
{
}

public function getPackage(): Package
{
return $this->package;
}
}
57 changes: 57 additions & 0 deletions src/EventListener/IntegrationListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Packeton\EventListener;

use Packeton\Event\PackageEvent;
use Packeton\Event\UpdaterEvent;
use Packeton\Integrations\IntegrationRegistry;
use Packeton\Integrations\Model\IntegrationUtils;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

class IntegrationListener
{
public function __construct(
protected IntegrationRegistry $integrations,
protected LoggerInterface $logger
) {
}

#[AsEventListener(event: 'packageCreate')]
public function onPackageCreate(PackageEvent $event): void
{
$package = $event->getPackage();
if (null === ($oauth = $package->getIntegration()) || empty($package->getExternalRef())) {
return;
}

try {
$app = $this->integrations->findApp($oauth->getAlias());
$info = $app->addHook($oauth, $package->getExternalRef());
if ($info['status'] ?? false) {
$package->setAutoUpdated(true);
}
} catch (\Throwable $e) {
$info = ['status' => false, 'error' => IntegrationUtils::castError($e, $app ?? null)];
}

$package->setWebhookInfo($info);
}

#[AsEventListener(event: 'packageRemove')]
public function onPackageDelete(UpdaterEvent $event): void
{
$package = $event->getPackage();
if (null === ($oauth = $package->getIntegration()) || empty($package->getExternalRef())) {
return;
}

try {
$app = $this->integrations->findApp($oauth->getAlias(), false);
$app->removeHook($oauth, $package->getExternalRef());
} catch (\Throwable $e) {
}
}
}
3 changes: 2 additions & 1 deletion src/Form/Type/IntegrationSettingsType.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Packeton\Form\Type;

use Packeton\Entity\OAuthIntegration;
use Packeton\Util\PacketonUtils;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
Expand All @@ -16,7 +17,7 @@ class IntegrationSettingsType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$repos = $options['repos'];
$repos = array_combine(array_column($repos, 'label'), array_column($repos, 'name'));
$repos = PacketonUtils::buildChoices($repos, 'label', 'name');

$builder
->add('globFilter', TextareaType::class, [
Expand Down
Loading

0 comments on commit 17fab5f

Please sign in to comment.