GitHubUserMigrationWorker.php 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. <?php declare(strict_types=1);
  2. namespace Packagist\WebBundle\Service;
  3. use Psr\Log\LoggerInterface;
  4. use Symfony\Bridge\Doctrine\RegistryInterface;
  5. use Packagist\WebBundle\Entity\Package;
  6. use Packagist\WebBundle\Entity\User;
  7. use Packagist\WebBundle\Entity\Job;
  8. use Seld\Signal\SignalHandler;
  9. use GuzzleHttp\Client;
  10. use GuzzleHttp\Psr7\Response;
  11. class GitHubUserMigrationWorker
  12. {
  13. const HOOK_URL = 'https://packagist.org/api/github';
  14. const HOOK_URL_ALT = 'https://packagist.org/api/update-package';
  15. private $logger;
  16. private $doctrine;
  17. private $guzzle;
  18. private $webhookSecret;
  19. public function __construct(LoggerInterface $logger, RegistryInterface $doctrine, Client $guzzle, string $webhookSecret)
  20. {
  21. $this->logger = $logger;
  22. $this->doctrine = $doctrine;
  23. $this->guzzle = $guzzle;
  24. $this->webhookSecret = $webhookSecret;
  25. }
  26. public function process(Job $job, SignalHandler $signal): array
  27. {
  28. $em = $this->doctrine->getManager();
  29. $id = $job->getPayload()['id'];
  30. $packageRepository = $em->getRepository(Package::class);
  31. $userRepository = $em->getRepository(User::class);
  32. /** @var User $user */
  33. $user = $userRepository->findOneById($id);
  34. if (!$user) {
  35. $this->logger->info('User is gone, skipping', ['id' => $id]);
  36. return ['status' => Job::STATUS_COMPLETED, 'message' => 'User was deleted, skipped'];
  37. }
  38. try {
  39. $results = ['hooks_setup' => 0, 'hooks_failed' => [], 'hooks_ok_unchanged' => 0];
  40. foreach ($packageRepository->getGitHubPackagesByMaintainer($id) as $package) {
  41. $result = $this->setupWebHook($user->getGithubToken(), $package);
  42. if (is_string($result)) {
  43. $results['hooks_failed'][] = ['package' => $package->getName(), 'reason' => $result];
  44. } elseif ($result === true) {
  45. $results['hooks_setup']++;
  46. } elseif ($result === false) {
  47. $results['hooks_ok_unchanged']++;
  48. }
  49. // null result means not processed as not a github-like URL
  50. }
  51. } catch (\GuzzleHttp\Exception\ServerException $e) {
  52. return [
  53. 'status' => Job::STATUS_RESCHEDULE,
  54. 'message' => 'Got error, rescheduling: '.$e->getMessage(),
  55. 'after' => new \DateTime('+5 minutes'),
  56. ];
  57. } catch (\GuzzleHttp\Exception\ConnectException $e) {
  58. return [
  59. 'status' => Job::STATUS_RESCHEDULE,
  60. 'message' => 'Got error, rescheduling: '.$e->getMessage(),
  61. 'after' => new \DateTime('+5 minutes'),
  62. ];
  63. }
  64. return [
  65. 'status' => Job::STATUS_COMPLETED,
  66. 'message' => 'Hooks updated for user '.$user->getUsername(),
  67. 'results' => $results,
  68. ];
  69. }
  70. public function setupWebHook(string $token, Package $package)
  71. {
  72. if (!preg_match('#^(?:(?:https?|git)://([^/]+)/|git@([^:]+):)(?P<owner>[^/]+)/(?P<repo>.+?)(?:\.git|/)?$#', $package->getRepository(), $match)) {
  73. return;
  74. }
  75. $this->logger->debug('Updating hooks for package '.$package->getName());
  76. $repoKey = $match['owner'].'/'.$match['repo'];
  77. $changed = false;
  78. try {
  79. $hooks = $this->getHooks($token, $repoKey);
  80. $legacyHooks = array_values(array_filter(
  81. $hooks,
  82. function ($hook) {
  83. return $hook['name'] === 'packagist' && $hook['active'] === true;
  84. }
  85. ));
  86. $currentHooks = array_values(array_filter(
  87. $hooks,
  88. function ($hook) {
  89. return $hook['name'] === 'web' && (strpos($hook['config']['url'], self::HOOK_URL) === 0 || strpos($hook['config']['url'], self::HOOK_URL_ALT) === 0);
  90. }
  91. ));
  92. $hookData = $this->getGitHubHookData();
  93. $hasValidHook = false;
  94. foreach ($currentHooks as $index => $hook) {
  95. $expectedConfigWithoutSecret = $hookData['config'];
  96. $configWithoutSecret = $hook['config'];
  97. unset($configWithoutSecret['secret'], $expectedConfigWithoutSecret['secret']);
  98. if ($hook['updated_at'] < '2018-09-04T13:00:00' || $hook['events'] != $hookData['events'] || $configWithoutSecret != $expectedConfigWithoutSecret || !$hook['active']) {
  99. $this->logger->debug('Updating hook '.$hook['id']);
  100. $this->request($token, 'PATCH', 'repos/'.$repoKey.'/hooks/'.$hook['id'], $hookData);
  101. $changed = true;
  102. }
  103. $hasValidHook = true;
  104. unset($currentHooks[$index]);
  105. }
  106. foreach (array_merge(array_values($currentHooks), $legacyHooks) as $hook) {
  107. $this->logger->debug('Deleting hook '.$hook['id'], ['hook' => $hook]);
  108. $this->request($token, 'DELETE', 'repos/'.$repoKey.'/hooks/'.$hook['id']);
  109. $changed = true;
  110. }
  111. if (!$hasValidHook) {
  112. $this->logger->debug('Creating hook');
  113. $resp = $this->request($token, 'POST', 'repos/'.$repoKey.'/hooks', $hookData);
  114. if ($resp->getStatusCode() === 201) {
  115. $hooks[] = json_decode((string) $resp->getBody(), true);
  116. $changed = true;
  117. }
  118. }
  119. if (count($hooks) && !preg_match('{^https://api\.github\.com/repos/'.$repoKey.'/hooks/}', $hooks[0]['url'])) {
  120. if (preg_match('https://api\.github\.com/repos/([^/]+/[^/]+)/hooks', $hooks[0]['url'], $match)) {
  121. $package->setRepository('https://github.com/'.$match[1]);
  122. $this->doctrine->getManager()->flush($package);
  123. }
  124. }
  125. } catch (\GuzzleHttp\Exception\ClientException $e) {
  126. if ($msg = $this->isAcceptableException($e)) {
  127. $this->logger->debug($msg);
  128. return $msg;
  129. }
  130. $this->logger->error('Rejected GitHub hook request', ['response' => (string) $e->getResponse()->getBody()]);
  131. throw $e;
  132. }
  133. return $changed;
  134. }
  135. public function deleteWebHook(string $token, Package $package): bool
  136. {
  137. if (!preg_match('#^(?:(?:https?|git)://([^/]+)/|git@([^:]+):)(?P<owner>[^/]+)/(?P<repo>.+?)(?:\.git|/)?$#', $package->getRepository(), $match)) {
  138. return true;
  139. }
  140. $this->logger->debug('Deleting hooks for package '.$package->getName());
  141. $repoKey = $match['owner'].'/'.$match['repo'];
  142. try {
  143. $hooks = $this->getHooks($token, $repoKey);
  144. foreach ($hooks as $hook) {
  145. if ($hook['name'] === 'web' && strpos($hook['config']['url'], self::HOOK_URL) === 0) {
  146. $this->logger->debug('Deleting hook '.$hook['id'], ['hook' => $hook]);
  147. $this->request($token, 'DELETE', 'repos/'.$repoKey.'/hooks/'.$hook['id']);
  148. }
  149. }
  150. } catch (\GuzzleHttp\Exception\ClientException $e) {
  151. if ($msg = $this->isAcceptableException($e)) {
  152. $this->logger->debug($msg);
  153. return false;
  154. }
  155. throw $e;
  156. }
  157. return true;
  158. }
  159. private function getHooks(string $token, string $repoKey): array
  160. {
  161. $hooks = [];
  162. $page = '';
  163. do {
  164. $resp = $this->request($token, 'GET', 'repos/'.$repoKey.'/hooks'.$page);
  165. $hooks = array_merge($hooks, json_decode((string) $resp->getBody(), true));
  166. $hasNext = false;
  167. foreach ($resp->getHeader('Link') as $header) {
  168. if (preg_match('{<https://api.github.com/resource?page=(?P<page>\d+)>; rel="next"}', $header, $match)) {
  169. $hasNext = true;
  170. $page = '?page='.$match['page'];
  171. }
  172. }
  173. } while ($hasNext);
  174. return $hooks;
  175. }
  176. private function request(string $token, string $method, string $url, array $json = null): Response
  177. {
  178. if (strpos($url, '?')) {
  179. $url .= '&access_token='.$token;
  180. } else {
  181. $url .= '?access_token='.$token;
  182. }
  183. $opts = [
  184. 'headers' => ['Accept' => 'application/vnd.github.v3+json'],
  185. ];
  186. if ($json) {
  187. $opts['json'] = $json;
  188. }
  189. return $this->guzzle->request($method, 'https://api.github.com/' . $url, $opts);
  190. }
  191. private function getGitHubHookData(): array
  192. {
  193. return [
  194. 'name' => 'web',
  195. 'config' => [
  196. 'url' => self::HOOK_URL,
  197. 'content_type' => 'json',
  198. 'secret' => $this->webhookSecret,
  199. 'insecure_ssl' => 0,
  200. ],
  201. 'events' => [
  202. 'push',
  203. ],
  204. 'active' => true,
  205. ];
  206. }
  207. private function isAcceptableException(\Throwable $e)
  208. {
  209. // repo not found probably means the user does not have admin access to it on github
  210. if ($e->getCode() === 404) {
  211. 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>)';
  212. }
  213. if ($e->getCode() === 403 && strpos($e->getMessage(), 'Repository was archived so is read-only') !== false) {
  214. return 'The repository is archived and read-only';
  215. }
  216. return false;
  217. }
  218. }