123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263 |
- <?php declare(strict_types=1);
- namespace Packagist\WebBundle\Service;
- use Psr\Log\LoggerInterface;
- use Symfony\Bridge\Doctrine\RegistryInterface;
- use Packagist\WebBundle\Entity\Package;
- use Packagist\WebBundle\Entity\User;
- use Packagist\WebBundle\Entity\Job;
- use Seld\Signal\SignalHandler;
- use GuzzleHttp\Client;
- use GuzzleHttp\Psr7\Response;
- class GitHubUserMigrationWorker
- {
- const HOOK_URL = 'https://packagist.org/api/github';
- const HOOK_URL_ALT = 'https://packagist.org/api/update-package';
- private $logger;
- private $doctrine;
- private $guzzle;
- private $webhookSecret;
- public function __construct(LoggerInterface $logger, RegistryInterface $doctrine, Client $guzzle, string $webhookSecret)
- {
- $this->logger = $logger;
- $this->doctrine = $doctrine;
- $this->guzzle = $guzzle;
- $this->webhookSecret = $webhookSecret;
- }
- public function process(Job $job, SignalHandler $signal): array
- {
- $em = $this->doctrine->getManager();
- $id = $job->getPayload()['id'];
- $packageRepository = $em->getRepository(Package::class);
- $userRepository = $em->getRepository(User::class);
- /** @var User $user */
- $user = $userRepository->findOneById($id);
- if (!$user) {
- $this->logger->info('User is gone, skipping', ['id' => $id]);
- return ['status' => Job::STATUS_COMPLETED, 'message' => 'User was deleted, skipped'];
- }
- try {
- $results = ['hooks_setup' => 0, 'hooks_failed' => [], 'hooks_ok_unchanged' => 0];
- foreach ($packageRepository->getGitHubPackagesByMaintainer($id) as $package) {
- $result = $this->setupWebHook($user->getGithubToken(), $package);
- if (is_string($result)) {
- $results['hooks_failed'][] = ['package' => $package->getName(), 'reason' => $result];
- } elseif ($result === true) {
- $results['hooks_setup']++;
- } elseif ($result === false) {
- $results['hooks_ok_unchanged']++;
- }
- // null result means not processed as not a github-like URL
- }
- } catch (\GuzzleHttp\Exception\ServerException $e) {
- return [
- 'status' => Job::STATUS_RESCHEDULE,
- 'message' => 'Got error, rescheduling: '.$e->getMessage(),
- 'after' => new \DateTime('+5 minutes'),
- ];
- } catch (\GuzzleHttp\Exception\ConnectException $e) {
- return [
- 'status' => Job::STATUS_RESCHEDULE,
- 'message' => 'Got error, rescheduling: '.$e->getMessage(),
- 'after' => new \DateTime('+5 minutes'),
- ];
- }
- return [
- 'status' => Job::STATUS_COMPLETED,
- 'message' => 'Hooks updated for user '.$user->getUsername(),
- 'results' => $results,
- ];
- }
- public function setupWebHook(string $token, Package $package)
- {
- if (!preg_match('#^(?:(?:https?|git)://([^/]+)/|git@([^:]+):)(?P<owner>[^/]+)/(?P<repo>.+?)(?:\.git|/)?$#', $package->getRepository(), $match)) {
- return;
- }
- $this->logger->debug('Updating hooks for package '.$package->getName());
- $repoKey = $match['owner'].'/'.$match['repo'];
- $changed = false;
- try {
- $hooks = $this->getHooks($token, $repoKey);
- $legacyHooks = array_values(array_filter(
- $hooks,
- function ($hook) {
- return $hook['name'] === 'packagist' && $hook['active'] === true;
- }
- ));
- $currentHooks = array_values(array_filter(
- $hooks,
- function ($hook) {
- return $hook['name'] === 'web' && (strpos($hook['config']['url'], self::HOOK_URL) === 0 || strpos($hook['config']['url'], self::HOOK_URL_ALT) === 0);
- }
- ));
- $hookData = $this->getGitHubHookData();
- $hasValidHook = false;
- foreach ($currentHooks as $index => $hook) {
- $expectedConfigWithoutSecret = $hookData['config'];
- $configWithoutSecret = $hook['config'];
- unset($configWithoutSecret['secret'], $expectedConfigWithoutSecret['secret']);
- if ($hook['updated_at'] < '2018-09-04T13:00:00' || $hook['events'] != $hookData['events'] || $configWithoutSecret != $expectedConfigWithoutSecret || !$hook['active']) {
- $this->logger->debug('Updating hook '.$hook['id']);
- $this->request($token, 'PATCH', 'repos/'.$repoKey.'/hooks/'.$hook['id'], $hookData);
- $changed = true;
- }
- $hasValidHook = true;
- unset($currentHooks[$index]);
- }
- foreach (array_merge(array_values($currentHooks), $legacyHooks) as $hook) {
- $this->logger->debug('Deleting hook '.$hook['id'], ['hook' => $hook]);
- $this->request($token, 'DELETE', 'repos/'.$repoKey.'/hooks/'.$hook['id']);
- $changed = true;
- }
- if (!$hasValidHook) {
- $this->logger->debug('Creating hook');
- $resp = $this->request($token, 'POST', 'repos/'.$repoKey.'/hooks', $hookData);
- if ($resp->getStatusCode() === 201) {
- $hooks[] = json_decode((string) $resp->getBody(), true);
- $changed = true;
- }
- }
- if (count($hooks) && !preg_match('{^https://api\.github\.com/repos/'.$repoKey.'/hooks/}', $hooks[0]['url'])) {
- if (preg_match('https://api\.github\.com/repos/([^/]+/[^/]+)/hooks', $hooks[0]['url'], $match)) {
- $package->setRepository('https://github.com/'.$match[1]);
- $this->doctrine->getManager()->flush($package);
- }
- }
- } catch (\GuzzleHttp\Exception\ClientException $e) {
- if ($msg = $this->isAcceptableException($e)) {
- $this->logger->debug($msg);
- return $msg;
- }
- $this->logger->error('Rejected GitHub hook request', ['response' => (string) $e->getResponse()->getBody()]);
- throw $e;
- }
- return $changed;
- }
- public function deleteWebHook(string $token, Package $package): bool
- {
- if (!preg_match('#^(?:(?:https?|git)://([^/]+)/|git@([^:]+):)(?P<owner>[^/]+)/(?P<repo>.+?)(?:\.git|/)?$#', $package->getRepository(), $match)) {
- return true;
- }
- $this->logger->debug('Deleting hooks for package '.$package->getName());
- $repoKey = $match['owner'].'/'.$match['repo'];
- try {
- $hooks = $this->getHooks($token, $repoKey);
- foreach ($hooks as $hook) {
- if ($hook['name'] === 'web' && strpos($hook['config']['url'], self::HOOK_URL) === 0) {
- $this->logger->debug('Deleting hook '.$hook['id'], ['hook' => $hook]);
- $this->request($token, 'DELETE', 'repos/'.$repoKey.'/hooks/'.$hook['id']);
- }
- }
- } catch (\GuzzleHttp\Exception\ClientException $e) {
- if ($msg = $this->isAcceptableException($e)) {
- $this->logger->debug($msg);
- return false;
- }
- throw $e;
- }
- return true;
- }
- private function getHooks(string $token, string $repoKey): array
- {
- $hooks = [];
- $page = '';
- do {
- $resp = $this->request($token, 'GET', 'repos/'.$repoKey.'/hooks'.$page);
- $hooks = array_merge($hooks, json_decode((string) $resp->getBody(), true));
- $hasNext = false;
- foreach ($resp->getHeader('Link') as $header) {
- if (preg_match('{<https://api.github.com/resource?page=(?P<page>\d+)>; rel="next"}', $header, $match)) {
- $hasNext = true;
- $page = '?page='.$match['page'];
- }
- }
- } while ($hasNext);
- return $hooks;
- }
- private function request(string $token, string $method, string $url, array $json = null): Response
- {
- if (strpos($url, '?')) {
- $url .= '&access_token='.$token;
- } else {
- $url .= '?access_token='.$token;
- }
- $opts = [
- 'headers' => ['Accept' => 'application/vnd.github.v3+json'],
- ];
- if ($json) {
- $opts['json'] = $json;
- }
- return $this->guzzle->request($method, 'https://api.github.com/' . $url, $opts);
- }
- private function getGitHubHookData(): array
- {
- return [
- 'name' => 'web',
- 'config' => [
- 'url' => self::HOOK_URL,
- 'content_type' => 'json',
- 'secret' => $this->webhookSecret,
- 'insecure_ssl' => 0,
- ],
- 'events' => [
- 'push',
- ],
- 'active' => true,
- ];
- }
- private function isAcceptableException(\Throwable $e)
- {
- // repo not found probably means the user does not have admin access to it on github
- if ($e->getCode() === 404) {
- return 'GitHub user has no admin access to the repository, or Packagist was not granted access to the organization (<a href="https://github.com/settings/connections/applications/a059f127e1c09c04aa5a">check here</a>)';
- }
- if ($e->getCode() === 403 && strpos($e->getMessage(), 'Repository was archived so is read-only') !== false) {
- return 'The repository is archived and read-only';
- }
- return false;
- }
- }
|