Skip to content

Commit 5892cfe

Browse files
authored
Merge pull request #115 from utopia-php/feat/presigned-repo-url
feat: presigned URL to download repository archive
2 parents f19c61d + 854f6b9 commit 5892cfe

7 files changed

Lines changed: 216 additions & 2 deletions

File tree

src/VCS/Adapter.php

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,22 @@ abstract public function getCommit(string $owner, string $repositoryName, string
369369
*/
370370
abstract public function getLatestCommit(string $owner, string $repositoryName, string $branch): array;
371371

372+
/**
373+
* Get a short-lived presigned URL to download the repository archive.
374+
*
375+
* @param string $owner Owner name of the repository
376+
* @param string $repositoryName Name of the repository
377+
* @param string $ref Branch, tag or commit to download (defaults to the default branch)
378+
* @param string $format Archive format, e.g. 'tarball' or 'zipball'
379+
* @return string Presigned download URL
380+
*
381+
* @throws Exception when the adapter does not implement it (opt-in, mirrors createCheckRun())
382+
*/
383+
public function getRepositoryPresignedUrl(string $owner, string $repositoryName, string $ref = '', string $format = 'tarball'): string
384+
{
385+
throw new Exception('getRepositoryPresignedUrl() is not implemented for ' . $this->getName());
386+
}
387+
372388
/**
373389
* Call
374390
*
@@ -379,11 +395,12 @@ abstract public function getLatestCommit(string $owner, string $repositoryName,
379395
* @param array<mixed> $params
380396
* @param array<string, string> $headers
381397
* @param bool $decode
398+
* @param bool $followRedirects When false, a redirect response is returned as-is instead of being followed
382399
* @return array<mixed>
383400
*
384401
* @throws Exception
385402
*/
386-
protected function call(string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true)
403+
protected function call(string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, bool $followRedirects = true)
387404
{
388405
$headers = array_merge($this->headers, $headers);
389406
$ch = curl_init($this->endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : ''));
@@ -423,7 +440,7 @@ protected function call(string $method, string $path = '', array $headers = [],
423440
curl_setopt($ch, CURLOPT_PATH_AS_IS, 1);
424441
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
425442
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
426-
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
443+
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, $followRedirects);
427444
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36');
428445
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
429446
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0);

src/VCS/Adapter/Git/GitHub.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,50 @@ public function getLatestCommit(string $owner, string $repositoryName, string $b
871871
];
872872
}
873873

874+
/**
875+
* Get a short-lived presigned URL to download the repository archive.
876+
*
877+
* GitHub answers its tarball/zipball endpoints with a temporary redirect to a
878+
* signed codeload URL. We capture that URL instead of following the redirect,
879+
* so callers can download the archive directly without proxying the token.
880+
*
881+
* @param string $owner Owner name of the repository
882+
* @param string $repositoryName Name of the repository
883+
* @param string $ref Branch, tag or commit to download (defaults to the default branch)
884+
* @param string $format Archive format: 'tarball' or 'zipball'
885+
* @return string Presigned download URL
886+
*/
887+
public function getRepositoryPresignedUrl(string $owner, string $repositoryName, string $ref = '', string $format = 'tarball'): string
888+
{
889+
if (!\in_array($format, ['tarball', 'zipball'], true)) {
890+
throw new Exception("Invalid archive format: {$format}. Use 'tarball' or 'zipball'.");
891+
}
892+
893+
$url = "/repos/$owner/$repositoryName/$format";
894+
if (!empty($ref)) {
895+
// Encode the ref but keep slashes so nested branch names (e.g. feature/foo) still resolve
896+
$url .= '/' . \str_replace('%2F', '/', \rawurlencode($ref));
897+
}
898+
899+
$response = $this->call(self::METHOD_GET, $url, ['Authorization' => "Bearer $this->accessToken"], [], false, false);
900+
901+
$responseHeaders = $response['headers'] ?? [];
902+
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
903+
if ($responseHeadersStatusCode === 404) {
904+
throw new RepositoryNotFound("Repository or ref not found.");
905+
}
906+
if ($responseHeadersStatusCode === 401 || $responseHeadersStatusCode === 403) {
907+
throw new Exception("Access denied to repository archive; check the access token and its permissions.", $responseHeadersStatusCode);
908+
}
909+
910+
$presignedUrl = $responseHeaders['location'] ?? '';
911+
if (empty($presignedUrl)) {
912+
throw new Exception("Failed to get presigned URL: HTTP {$responseHeadersStatusCode}", $responseHeadersStatusCode);
913+
}
914+
915+
return $presignedUrl;
916+
}
917+
874918
/**
875919
* Updates status check of each commit
876920
* state can be one of: error, failure, pending, success

src/VCS/Adapter/Git/GitLab.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,40 @@ public function getRepository(string $owner, string $repositoryName): array
162162
}
163163

164164

165+
/**
166+
* Get a short-lived presigned URL to download the repository archive.
167+
*
168+
* GitLab only signs archive URLs when backed by object storage, so we build
169+
* a directly downloadable archive URL with the access token embedded as a
170+
* query parameter. Since the adapter's tokens are short-lived, the URL is
171+
* effectively time-limited. Note the URL carries the token; treat it as a
172+
* secret.
173+
*
174+
* @param string $owner Owner name of the repository
175+
* @param string $repositoryName Name of the repository
176+
* @param string $ref Branch, tag or commit to download (defaults to the default branch)
177+
* @param string $format Archive format: 'tarball' or 'zipball'
178+
* @return string Presigned download URL
179+
*/
180+
public function getRepositoryPresignedUrl(string $owner, string $repositoryName, string $ref = '', string $format = 'tarball'): string
181+
{
182+
$extension = match ($format) {
183+
'tarball' => 'tar.gz',
184+
'zipball' => 'zip',
185+
default => throw new Exception("Invalid archive format: {$format}. Use 'tarball' or 'zipball'."),
186+
};
187+
188+
$ownerPath = $this->getOwnerPath($owner);
189+
$projectPath = urlencode("{$ownerPath}/{$repositoryName}");
190+
191+
$url = "{$this->endpoint}/projects/{$projectPath}/repository/archive.{$extension}?private_token=" . urlencode($this->accessToken);
192+
if (!empty($ref)) {
193+
$url .= "&sha=" . urlencode($ref);
194+
}
195+
196+
return $url;
197+
}
198+
165199
public function hasAccessToAllRepositories(): bool
166200
{
167201
return true;

src/VCS/Adapter/Git/Gitea.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,42 @@ public function getRepository(string $owner, string $repositoryName): array
260260
return is_array($result) ? $result : [];
261261
}
262262

263+
/**
264+
* Get a short-lived presigned URL to download the repository archive.
265+
*
266+
* Gitea has no signing service for local storage, so we build a directly
267+
* downloadable archive URL with the access token embedded as a query
268+
* parameter. Since the adapter's tokens are short-lived, the URL is
269+
* effectively time-limited. Note the URL carries the token; treat it as a
270+
* secret.
271+
*
272+
* @param string $owner Owner name of the repository
273+
* @param string $repositoryName Name of the repository
274+
* @param string $ref Branch, tag or commit to download (defaults to the default branch)
275+
* @param string $format Archive format: 'tarball' or 'zipball'
276+
* @return string Presigned download URL
277+
*/
278+
public function getRepositoryPresignedUrl(string $owner, string $repositoryName, string $ref = '', string $format = 'tarball'): string
279+
{
280+
$extension = match ($format) {
281+
'tarball' => 'tar.gz',
282+
'zipball' => 'zip',
283+
default => throw new Exception("Invalid archive format: {$format}. Use 'tarball' or 'zipball'."),
284+
};
285+
286+
if (empty($ref)) {
287+
$ref = $this->getRepository($owner, $repositoryName)['default_branch'] ?? '';
288+
if (empty($ref)) {
289+
throw new Exception('Unable to resolve default branch for archive download.');
290+
}
291+
}
292+
293+
// Encode the ref but keep slashes so nested branch names (e.g. feature/foo) still resolve
294+
$encodedRef = \str_replace('%2F', '/', \rawurlencode($ref));
295+
296+
return "{$this->endpoint}/repos/{$owner}/{$repositoryName}/archive/{$encodedRef}.{$extension}?token=" . urlencode($this->accessToken);
297+
}
298+
263299
public function getRepositoryName(string $repositoryId): string
264300
{
265301
$url = "/repositories/{$repositoryId}";

tests/VCS/Adapter/GitHubTest.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,42 @@ public function testGetCommit(): void
516516
}
517517
}
518518

519+
public function testGetRepositoryPresignedUrl(): void
520+
{
521+
$repositoryName = 'test-presigned-url-' . \uniqid();
522+
$this->vcsAdapter->createRepository(static::$owner, $repositoryName, false);
523+
524+
try {
525+
$this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test');
526+
527+
/** @var GitHub $adapter */
528+
$adapter = $this->vcsAdapter;
529+
530+
$tarballUrl = $adapter->getRepositoryPresignedUrl(static::$owner, $repositoryName, static::$defaultBranch);
531+
$this->assertNotEmpty($tarballUrl);
532+
$this->assertStringStartsWith('https://', $tarballUrl);
533+
534+
$zipballUrl = $adapter->getRepositoryPresignedUrl(static::$owner, $repositoryName, static::$defaultBranch, 'zipball');
535+
$this->assertNotEmpty($zipballUrl);
536+
$this->assertStringStartsWith('https://', $zipballUrl);
537+
538+
// Defaults to the default branch when no ref is given
539+
$defaultUrl = $adapter->getRepositoryPresignedUrl(static::$owner, $repositoryName);
540+
$this->assertNotEmpty($defaultUrl);
541+
} finally {
542+
$this->vcsAdapter->deleteRepository(static::$owner, $repositoryName);
543+
}
544+
}
545+
546+
public function testGetRepositoryPresignedUrlWithInvalidFormat(): void
547+
{
548+
/** @var GitHub $adapter */
549+
$adapter = $this->vcsAdapter;
550+
551+
$this->expectException(\Exception::class);
552+
$adapter->getRepositoryPresignedUrl(static::$owner, 'some-repo', static::$defaultBranch, 'invalid');
553+
}
554+
519555
public function testGetCommitWithInvalidHash(): void
520556
{
521557
$repositoryName = 'test-get-commit-invalid-' . \uniqid();

tests/VCS/Adapter/GitLabTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,27 @@ protected function setupGitLab(): void
6363
}
6464

6565

66+
public function testGetRepositoryPresignedUrl(): void
67+
{
68+
/** @var GitLab $adapter */
69+
$adapter = $this->vcsAdapter;
70+
$owner = static::$owner;
71+
72+
$url = $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch);
73+
$this->assertStringContainsString('/repository/archive.tar.gz?private_token=', $url);
74+
$this->assertStringContainsString('&sha=' . static::$defaultBranch, $url);
75+
76+
$zip = $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch, 'zipball');
77+
$this->assertStringContainsString('/repository/archive.zip?private_token=', $zip);
78+
79+
// Without a ref the sha param is omitted so the server uses the default branch
80+
$noRef = $adapter->getRepositoryPresignedUrl($owner, 'some-repo');
81+
$this->assertStringNotContainsString('sha=', $noRef);
82+
83+
$this->expectException(\Exception::class);
84+
$adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch, 'invalid');
85+
}
86+
6687
public function testCreateRepository(): void
6788
{
6889
$repositoryName = 'test-create-repository-' . \uniqid();

tests/VCS/Adapter/GiteaTest.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,32 @@ public function testListBranchesEmptyRepo(): void
7979
}
8080
}
8181

82+
public function testGetRepositoryPresignedUrl(): void
83+
{
84+
/** @var Gitea $adapter */
85+
$adapter = $this->vcsAdapter;
86+
$owner = static::$owner;
87+
88+
$url = $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch);
89+
$this->assertStringContainsString("/repos/{$owner}/some-repo/archive/" . static::$defaultBranch . '.tar.gz?token=', $url);
90+
91+
$zip = $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch, 'zipball');
92+
$this->assertStringContainsString('.zip?token=', $zip);
93+
94+
// No ref: the default branch is resolved from the repository
95+
$repositoryName = 'test-presigned-url-' . \uniqid();
96+
$adapter->createRepository($owner, $repositoryName, false);
97+
try {
98+
$noRef = $adapter->getRepositoryPresignedUrl($owner, $repositoryName);
99+
$this->assertStringContainsString("/archive/" . static::$defaultBranch . '.tar.gz?token=', $noRef);
100+
} finally {
101+
$adapter->deleteRepository($owner, $repositoryName);
102+
}
103+
104+
$this->expectException(\Exception::class);
105+
$adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch, 'invalid');
106+
}
107+
82108
public function testCreateRepository(): void
83109
{
84110
$owner = static::$owner;

0 commit comments

Comments
 (0)