Skip to content
Open
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
5 changes: 3 additions & 2 deletions app/Actions/SourceControl/ConnectSourceControl.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ public function connect(User $user, array $input): SourceControl
'provider' => $input['provider'],
'profile' => $input['name'],
'url' => isset($input['url']) && $input['url'] ? $input['url'] : null,
'port' => isset($input['port']) && $input['port'] ? (int) $input['port'] : null,
'project_id' => isset($input['global']) && $input['global'] ? null : $user->currentProject?->id,
Comment on lines 24 to 27
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

port is persisted from user input but there is no base validation for it. For non-Gitlab providers, providerRules() won't include port, yet this code will still store the value (including negatives / >65535), which can later break SSH cloning. Add a shared validation rule for port (e.g. nullable|integer|min:1|max:65535) in validate() or only allow persisting it when the active provider explicitly supports it.

Copilot uses AI. Check for mistakes.
'user_id' => $user->id,
]);

$sourceControl->provider_data = $sourceControl->provider()->createData($input);

try {
if (! $sourceControl->provider()->connect()) {
if (!$sourceControl->provider()->connect()) {
throw ValidationException::withMessages([
'provider' => __('Cannot connect to :provider or invalid credentials!', ['provider' => $sourceControl->provider]),
]);
Expand Down Expand Up @@ -75,7 +76,7 @@ private function validate(array $input): void
*/
private function providerRules(array $input): array
{
if (! isset($input['provider'])) {
if (!isset($input['provider'])) {
return [];
}

Expand Down
5 changes: 4 additions & 1 deletion app/Models/SourceControl.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* @property array<string, string> $provider_data
* @property string $profile
* @property ?string $url
* @property ?int $port
* @property string $access_token
* @property ?int $project_id
* @property int $user_id
Expand All @@ -33,6 +34,7 @@ class SourceControl extends AbstractModel
'provider_data',
'profile',
'url',
'port',
'access_token',
'project_id',
'user_id',
Expand All @@ -41,13 +43,14 @@ class SourceControl extends AbstractModel
protected $casts = [
'access_token' => 'encrypted',
'provider_data' => 'encrypted:array',
'port' => 'integer',
'project_id' => 'integer',
'user_id' => 'integer',
];

public function provider(): SourceControlProvider
{
$providerClass = config('source-control.providers.'.$this->provider.'.handler');
$providerClass = config('source-control.providers.' . $this->provider . '.handler');

/** @var SourceControlProvider $provider */
$provider = new $providerClass($this);
Expand Down
3 changes: 3 additions & 0 deletions app/Providers/SourceControlServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ private function gitlab(): void
DynamicField::make('url')
->text()
->label('Self hosted URL'),
DynamicField::make('port')
->text()
->label('SSH Port'),
])
)
->register();
Expand Down
1 change: 1 addition & 0 deletions app/SSH/OS/Git.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public function clone(Site $site, ?string $path = null): void
'path' => $path ?? $site->path,
'branch' => $site->branch,
'key' => $site->getSshKeyName(),
'port' => $site->sourceControl?->provider()?->getSshPort() ?? 22,
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior (passing a configurable SSH port into the clone script) isn’t covered by existing SourceControl connection tests. Consider adding a test that connects/creates a Gitlab source control with a non-22 port and asserts it’s persisted and used (or at least surfaced via getSshPort()), with defaulting to 22 when unset.

Copilot uses AI. Check for mistakes.
]),
'clone-repository',
$site->id
Expand Down
5 changes: 5 additions & 0 deletions app/SourceControlProviders/AbstractSourceControlProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,9 @@ public function getBranches(string $repo, bool $useCache = true): array
{
return [];
}

public function getSshPort(): ?int
{
return (int) $this->sourceControl->port ?: ((int) ($this->sourceControl->provider_data['port'] ?? null) ?: null);
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getSshPort() uses a nested cast/ternary that can return surprising results and doesn't enforce valid SSH port bounds. Consider normalizing with explicit null checks (prefer $this->sourceControl->port first) and returning null for out-of-range values so callers can safely default to 22.

Suggested change
return (int) $this->sourceControl->port ?: ((int) ($this->sourceControl->provider_data['port'] ?? null) ?: null);
$port = $this->sourceControl->port;
if ($port === null && isset($this->sourceControl->provider_data['port'])) {
$port = $this->sourceControl->provider_data['port'];
}
if ($port === null) {
return null;
}
$port = (int) $port;
if ($port < 1 || $port > 65535) {
return null;
}
return $port;

Copilot uses AI. Check for mistakes.
}
}
34 changes: 20 additions & 14 deletions app/SourceControlProviders/Gitlab.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,15 @@ public function createRules(array $input): array
'url:http,https',
'ends_with:/',
],
'port' => 'nullable|integer',
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new port field is optional but currently lacks bounds validation. SSH ports must be within 1-65535; allowing 0/negative/large values will cause connection failures (and can store invalid data in an unsignedInteger column). Consider changing the rule to include min/max (and keep it consistent with server port validation elsewhere in the codebase).

Suggested change
'port' => 'nullable|integer',
'port' => 'nullable|integer|min:1|max:65535',

Copilot uses AI. Check for mistakes.
];
}

public function connect(): bool
{
try {
$res = Http::withToken($this->data()['token'])
->get($this->getApiUrl().'/version');
->get($this->getApiUrl() . '/version');
} catch (Exception) {
return false;
}
Expand All @@ -60,7 +61,7 @@ public function getRepo(string $repo): mixed
{
$repository = $repo !== '' && $repo !== '0' ? urlencode($repo) : null;
$res = Http::withToken($this->data()['token'])
->get($this->getApiUrl().'/projects/'.$repository.'/repository/commits');
->get($this->getApiUrl() . '/projects/' . $repository . '/repository/commits');

$this->handleResponseErrors($res, $repo);

Expand All @@ -82,10 +83,10 @@ public function deployHook(string $repo, array $events, string $secret): array
$repository = urlencode($repo);
try {
$response = Http::withToken($this->data()['token'])->post(
$this->getApiUrl().'/projects/'.$repository.'/hooks',
$this->getApiUrl() . '/projects/' . $repository . '/hooks',
[
'description' => 'deploy',
'url' => url('/api/git-hooks?secret='.$secret),
'url' => url('/api/git-hooks?secret=' . $secret),
'push_events' => in_array('push', $events),
'issues_events' => false,
'job_events' => false,
Expand Down Expand Up @@ -121,7 +122,7 @@ public function destroyHook(string $repo, string $hookId): void
$repository = urlencode($repo);
try {
$response = Http::withToken($this->data()['token'])->delete(
$this->getApiUrl().'/projects/'.$repository.'/hooks/'.$hookId
$this->getApiUrl() . '/projects/' . $repository . '/hooks/' . $hookId
);
} catch (Exception $e) {
throw new FailedToDestroyGitHook($e->getMessage());
Expand All @@ -139,7 +140,7 @@ public function getLastCommit(string $repo, string $branch): ?array
{
$repository = urlencode($repo);
$res = Http::withToken($this->data()['token'])
->get($this->getApiUrl().'/projects/'.$repository.'/repository/commits?ref_name='.$branch);
->get($this->getApiUrl() . '/projects/' . $repository . '/repository/commits?ref_name=' . $branch);

$this->handleResponseErrors($res, $repo);

Expand Down Expand Up @@ -167,7 +168,7 @@ public function deployKey(string $title, string $repo, string $key): string
$repository = urlencode($repo);
try {
$response = Http::withToken($this->data()['token'])->post(
$this->getApiUrl().'/projects/'.$repository.'/deploy_keys',
$this->getApiUrl() . '/projects/' . $repository . '/deploy_keys',
[
'title' => $title,
'key' => $key,
Expand All @@ -190,10 +191,10 @@ public function deleteDeployKey(string $keyId, string $repo): void
try {
$repository = urlencode($repo);
$response = Http::withToken($this->data()['token'])->delete(
$this->getApiUrl().'/projects/'.$repository.'/deploy_keys/'.$keyId
$this->getApiUrl() . '/projects/' . $repository . '/deploy_keys/' . $keyId
);

if (! $response->successful()) {
if (!$response->successful()) {
Log::warning('Failed to delete Gitlab deploy key', [
'repo' => $repo,
'key_id' => $keyId,
Expand All @@ -210,16 +211,17 @@ public function deleteDeployKey(string $keyId, string $repo): void
}
}


public function getApiUrl(): string
{
$host = $this->sourceControl->url ?? $this->defaultApiHost;

return $host.$this->apiVersion;
return $host . $this->apiVersion;
}

public function getRepos(bool $useCache = true): array
{
$cacheKey = 'gitlab_repos_'.md5($this->getApiUrl().$this->data()['token']);
$cacheKey = 'gitlab_repos_' . md5($this->getApiUrl() . $this->data()['token']);

if ($useCache && Cache::has($cacheKey)) {
return Cache::get($cacheKey);
Expand Down Expand Up @@ -247,7 +249,7 @@ public function getRepos(bool $useCache = true): array

public function getBranches(string $repo, bool $useCache = true): array
{
$cacheKey = 'gitlab_branches_'.md5($repo.$this->getApiUrl().$this->data()['token']);
$cacheKey = 'gitlab_branches_' . md5($repo . $this->getApiUrl() . $this->data()['token']);

if ($useCache && Cache::has($cacheKey)) {
return Cache::get($cacheKey);
Expand Down Expand Up @@ -291,9 +293,9 @@ private function fetchAllPages(string $endpoint, array $params = []): Collection
while ($hasMore) {
$params['page'] = $page;
$response = Http::withToken($this->data()['token'])
->get($this->getApiUrl().$endpoint, $params);
->get($this->getApiUrl() . $endpoint, $params);

if (! $response->successful()) {
if (!$response->successful()) {
Log::error('GitLab API request failed', [
'endpoint' => $endpoint,
'status' => $response->status(),
Expand Down Expand Up @@ -336,4 +338,8 @@ private function fetchAllPages(string $endpoint, array $params = []): Collection

return $allData;
}
public function getSshPort(): ?int
{
return (int) $this->sourceControl->port ?: ((int) ($this->sourceControl->provider_data['port'] ?? null) ?: null);
}
}
2 changes: 1 addition & 1 deletion app/SourceControlProviders/SourceControlProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,6 @@ public function deleteDeployKey(string $keyId, string $repo): void;
public function getWebhookBranch(array $payload): string;

public function getRepos(bool $useCache = true): array;

public function getBranches(string $repo, bool $useCache = true): array;
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding getSshPort() to the SourceControlProvider interface is a backwards-incompatible change for any external/custom source-control providers registered via the plugin system. If this project supports third-party providers, consider providing a non-breaking default (e.g. via an optional trait/base class contract or a separate interface) or clearly documenting this as a breaking change.

Suggested change
public function getBranches(string $repo, bool $useCache = true): array;
public function getBranches(string $repo, bool $useCache = true): array;
}
interface SshAwareSourceControlProvider extends SourceControlProvider
{

Copilot uses AI. Check for mistakes.
public function getSshPort(): ?int;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
public function up(): void
{
Schema::table('source_controls', function (Blueprint $table): void {
$table->unsignedInteger('port')->nullable()->after('url');
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This migration uses unsignedInteger for an SSH port, but valid ports only range up to 65535. Using unsignedSmallInteger better models the domain and prevents accidental storage of out-of-range values.

Suggested change
$table->unsignedInteger('port')->nullable()->after('url');
$table->unsignedSmallInteger('port')->nullable()->after('url');

Copilot uses AI. Check for mistakes.
});
}

public function down(): void
{
Schema::table('source_controls', function (Blueprint $table): void {
$table->dropColumn('port');
});
}
};
19 changes: 10 additions & 9 deletions resources/views/ssh/git/clone.blade.php
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
echo "Host {{ $host }}-{{ $key }}
Hostname {{ $host }}
IdentityFile=~/.ssh/{{ $key }}" >> ~/.ssh/config
Hostname {{ $host }}
Port {{ $port }}
IdentityFile=~/.ssh/{{ $key }}" >> ~/.ssh/config
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In ~/.ssh/config, IdentityFile is normally specified as IdentityFile <path> (space-separated). Using IdentityFile=... risks being parsed as an unknown option and can make SSH ignore/fail the config, breaking cloning. Update the rendered config line to use the standard IdentityFile syntax.

Suggested change
IdentityFile=~/.ssh/{{ $key }}" >> ~/.ssh/config
IdentityFile ~/.ssh/{{ $key }}" >> ~/.ssh/config

Copilot uses AI. Check for mistakes.

chmod 600 ~/.ssh/config

ssh-keyscan -H {{ $host }} >> ~/.ssh/known_hosts
ssh-keyscan -p {{ $port }} -H {{ $host }} >> ~/.ssh/known_hosts

rm -rf {{ $path }}

if ! git config --global core.fileMode false; then
echo 'VITO_SSH_ERROR' && exit 1
echo 'VITO_SSH_ERROR' && exit 1
fi

if ! git clone -b {{ $branch }} {{ $repo }} {{ $path }}; then
echo 'VITO_SSH_ERROR' && exit 1
echo 'VITO_SSH_ERROR' && exit 1
fi

if ! find {{ $path }} -type d -exec chmod 755 {} \;; then
echo 'VITO_SSH_ERROR' && exit 1
echo 'VITO_SSH_ERROR' && exit 1
fi

if ! find {{ $path }} -type f -exec chmod 644 {} \;; then
echo 'VITO_SSH_ERROR' && exit 1
echo 'VITO_SSH_ERROR' && exit 1
fi

if ! cd {{ $path }} && git config core.fileMode false; then
echo 'VITO_SSH_ERROR' && exit 1
fi
echo 'VITO_SSH_ERROR' && exit 1
fi