Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
Expand All @@ -49,6 +50,8 @@ enum ApiType
*/
class ApiController extends Controller
{
private const COMPOSER_VERSIONS_URL = 'https://getcomposer.org/versions';

private const REGEXES = [
'gitlab' => '{^(?:ssh://git@|https?://|git://|git@)?(?P<host>[a-z0-9.-]+)(?::[0-9]+/|[:/])(?P<path>[\w.-]+(?:/[\w.-]+?)+)(?:\.git|/)?$}i',
'any' => '{^(?:ssh://git@|https?://|git://|git@)?(?P<host>[a-z0-9.-]+)(?::[0-9]+/|[:/])(?P<path>[\w.-]+(?:/[\w.-]+?)*)(?:\.git|/)?$}i',
Expand Down Expand Up @@ -82,6 +85,34 @@ public function packagesAction(string $webDir): Response
return new Response('Horrible misconfiguration or the dumper script messed up, you need to use bin/console packagist:dump-v2', 404);
}

/**
* Proxies https://getcomposer.org/versions so that systems which can only reach
* packagist.org are still able to read Composer's version metadata.
*
* The response is cached at the CDN edge via s-maxage as this data changes rarely.
*/
#[Route(path: '/api/composer-versions', name: 'composer_versions', defaults: ['_format' => 'json'], methods: ['GET'])]
public function composerVersionsAction(): Response
{
try {
$upstream = $this->httpClient->request('GET', self::COMPOSER_VERSIONS_URL);
if ($upstream->getStatusCode() !== 200) {
throw new \RuntimeException('Unexpected status code '.$upstream->getStatusCode());
}
$contents = $upstream->getContent();
} catch (\Throwable $e) {
$this->logger->error('Failed to proxy '.self::COMPOSER_VERSIONS_URL, ['exception' => $e]);

return new JsonResponse(['status' => 'error', 'message' => 'Failed to fetch upstream version data, please try again later.'], 502);
}

$response = new Response($contents, 200, ['Content-Type' => 'application/json']);
$response->setSharedMaxAge(900);
$response->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER, 'true');

return $response;
}

#[Route(path: '/api/create-package', name: 'generic_create', defaults: ['_format' => 'json'], methods: ['POST'])]
public function createPackageAction(Request $request, ProviderManager $providerManager, GitHubUserMigrationWorker $githubUserMigrationWorker, RouterInterface $router, ValidatorInterface $validator): JsonResponse
{
Expand Down
34 changes: 34 additions & 0 deletions tests/Controller/ApiControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
use App\Tests\IntegrationTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Depends;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

class ApiControllerTest extends IntegrationTestCase
{
Expand Down Expand Up @@ -282,4 +284,36 @@ public function testBearerTokenAuthenticationOverridesQuery(): void

$this->assertEquals(202, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
}

public function testComposerVersionsProxiesUpstream(): void
{
$body = '{"stable":[{"path":"/download/2.7.1/composer.phar","version":"2.7.1"}]}';
$mockClient = new MockHttpClient(function (string $method, string $url) use ($body) {
$this->assertSame('GET', $method);
$this->assertSame('https://getcomposer.org/versions', $url);

return new MockResponse($body, ['http_code' => 200, 'response_headers' => ['Content-Type' => 'application/json']]);
});
self::getContainer()->set('http_client', $mockClient);

$this->client->request('GET', '/api/composer-versions');
$response = $this->client->getResponse();

$this->assertSame(200, $response->getStatusCode(), (string) $response->getContent());
$this->assertSame($body, $response->getContent());
$this->assertStringContainsString('application/json', (string) $response->headers->get('Content-Type'));
$this->assertStringContainsString('s-maxage=900', (string) $response->headers->get('Cache-Control'));
}

public function testComposerVersionsReturns502OnUpstreamFailure(): void
{
$mockClient = new MockHttpClient(new MockResponse('nope', ['http_code' => 500]));
self::getContainer()->set('http_client', $mockClient);

$this->client->request('GET', '/api/composer-versions');
$response = $this->client->getResponse();

$this->assertSame(502, $response->getStatusCode());
$this->assertJsonStringEqualsJsonString('{"status":"error","message":"Failed to fetch upstream version data, please try again later."}', (string) $response->getContent());
}
}