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
12 changes: 12 additions & 0 deletions app/Enums/VcsPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,16 @@ public function getLabel(): string
self::GITLAB => __('GitLab'),
};
}

/**
* @return array<string, string>
*/
public static function getLabels(): array
{
$platforms = [];
foreach (self::cases() as $platform) {
$platforms[$platform->value] = $platform->getLabel();
}
return $platforms;
}
}
32 changes: 32 additions & 0 deletions app/Http/Controllers/VcsInstanceController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Enums\VcsPlatform;
use App\Http\Requests\VcsInstances\VcsInstanceCreateRequest;
use App\Models\VcsInstance;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;

class VcsInstanceController extends Controller
{
public function create(): Response
{
return Inertia::render('vcsInstances/Create', [
'platforms' => VcsPlatform::getLabels(),
]);
}

public function store(VcsInstanceCreateRequest $request): RedirectResponse
{
$vcsInstance = new VcsInstance($request->validated());
$vcsInstance->installation_id = $vcsInstance->platform === VcsPlatform::GITHUB ? $vcsInstance->installation_id : null;
$vcsInstance->token = $vcsInstance->platform === VcsPlatform::GITLAB ? $vcsInstance->token : null;
$vcsInstance->save();

return to_route('repositories.index');
}
}
21 changes: 21 additions & 0 deletions app/Http/Requests/VcsInstances/VcsInstanceCreateRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests\VcsInstances;

use App\Models\VcsInstance;
use Illuminate\Foundation\Http\FormRequest;

class VcsInstanceCreateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return VcsInstance::rules();
}
}
23 changes: 23 additions & 0 deletions app/Models/VcsInstance.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Validation\Rule;

/**
* Attributes
Expand All @@ -30,6 +31,14 @@ class VcsInstance extends Model

public $timestamps = false;

protected $fillable = [
'name',
'api_url',
'token',
'installation_id',
'platform',
];

protected function casts(): array
{
return [
Expand All @@ -38,6 +47,20 @@ protected function casts(): array
];
}

/**
* @return array<string, array<array-key, mixed>>
*/
public static function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'api_url' => ['required', 'url', 'max:255'],
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

api_url is later used to build outbound HTTP requests (e.g., GitHub/GitLab API calls). Validating with the generic url rule allows non-HTTP(S) schemes and potentially internal/loopback/private-network hosts, which can lead to misconfiguration and SSRF risk. Consider restricting to HTTPS and (if feasible) rejecting localhost/private IP ranges / non-public hosts at validation time.

Suggested change
'api_url' => ['required', 'url', 'max:255'],
'api_url' => [
'required',
'url',
'max:255',
/**
* Ensure the API URL uses HTTP(S) and does not point to localhost or private/reserved IP ranges.
*
* @param string $attribute
* @param mixed $value
* @param callable $fail
*/
function ($attribute, $value, $fail): void {
if (!is_string($value)) {
$fail('The ' . $attribute . ' must be a valid URL.');
return;
}
$parts = parse_url($value);
if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
$fail('The ' . $attribute . ' must be a valid URL.');
return;
}
$scheme = strtolower($parts['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) {
$fail('The ' . $attribute . ' must use the http or https scheme.');
return;
}
$host = strtolower($parts['host']);
// Explicitly disallow localhost.
if ($host === 'localhost') {
$fail('The ' . $attribute . ' must not point to localhost.');
return;
}
// If host is a literal IP address, ensure it is not private or reserved.
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
$publicIp = filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if ($publicIp === false) {
$fail('The ' . $attribute . ' must not point to a private or reserved IP address.');
}
}
},
],

Copilot uses AI. Check for mistakes.
'token' => ['required_if:platform,gitlab', 'nullable', 'string'],
'installation_id' => ['required_if:platform,github', 'nullable', 'string', 'max:255'],
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

installation_id is accepted as an arbitrary string, but it is used as a path parameter in GitHub API calls and is expected to be numeric. Allowing non-numeric values will pass validation but will fail at runtime when calling the GitHub API. Consider validating it as an integer/numeric value (and optionally enforcing a sensible range) when platform is GitHub.

Suggested change
'installation_id' => ['required_if:platform,github', 'nullable', 'string', 'max:255'],
'installation_id' => ['required_if:platform,github', 'nullable', 'integer', 'min:1', 'max:255'],

Copilot uses AI. Check for mistakes.
'platform' => ['required', Rule::enum(VcsPlatform::class)],
];
}

/**
* @return HasMany<Repository, $this>
*/
Expand Down
5 changes: 4 additions & 1 deletion resources/js/pages/repositories/Repositories.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ const props = defineProps<Props>();

<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl px-4 py-6">
<div class="flex justify-end">
<div class="flex justify-end gap-2">
<Link :href="route('repositories.create')" as="button">
<Button><Plus /> Add repository</Button>
</Link>
<Link :href="route('vcs-instances.create')" as="button">
<Button><Plus /> Add VCS instance</Button>
</Link>
</div>
<div class="container mx-auto space-y-4 pb-10">
<DataTable :columns="columns" :paginated-data="props.repositories" />
Expand Down
109 changes: 109 additions & 0 deletions resources/js/pages/vcsInstances/Create.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<script setup lang="ts">
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import AppLayout from '@/layouts/AppLayout.vue';
import type { BreadcrumbItem, VcsInstance } from '@/types';
import { Head, useForm } from '@inertiajs/vue3';

const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Repositories',
href: '/repositories',
},
{
title: 'Add VCS instance',
href: '#',
},
];

interface Props {
platforms: Record<string, string>;
}

defineProps<Props>();

const form = useForm<VcsInstance>({
name: '',
api_url: '',
token: '',
installation_id: '',
platform: 'gitlab',
});

const createVcsInstance = () => {
form.post(route('vcs-instances.store'), {
preserveScroll: true,
});
};
</script>

<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Add VCS instance" />

<div class="flex p-4">
<div class="mx-auto w-full py-10 sm:w-1/2">
<Heading title="Add VCS instance" />
<form @submit.prevent="createVcsInstance" class="space-y-6">
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" v-model="form.name" class="mt-1 block w-full" required />
<InputError :message="form.errors.name" />
</div>

<div class="grid gap-2">
<Label for="api_url">API URL</Label>
<Input id="api_url" v-model="form.api_url" class="mt-1 block w-full" required />
<InputError :message="form.errors.api_url" />
<p class="text-sm text-muted-foreground">Base API URL e.g. https://api.github.com/ or https://gitlab.com/api/</p>
</div>

<div class="grid gap-2">
<Label for="platform">Platform</Label>
<Select id="platform" v-model="form.platform" class="mt-1 block w-full" required>
<SelectTrigger>
<SelectValue placeholder="Select platform" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="(label, value) in platforms" :key="value" :value="value">{{ label }}</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.platform" />
</div>

<div class="grid gap-2" v-if="form.platform === 'github'">
<Label for="installation_id">Installation ID</Label>
<Input id="installation_id" v-model="form.installation_id" class="mt-1 block w-full" required />
<InputError :message="form.errors.installation_id" />
<p class="text-sm text-muted-foreground">
Installation ID could be found at Settings → Integrations → Applications → App as part of the URL
</p>
</div>

<div class="grid gap-2" v-if="form.platform === 'gitlab'">
<Label for="token">Token</Label>
<Input id="token" type="password" v-model="form.token" class="mt-1 block w-full" required />
<InputError :message="form.errors.token" />
</div>

<div class="flex items-center gap-4">
<Button :disabled="form.processing">Create VCS instance</Button>

<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p v-show="form.recentlySuccessful" class="text-sm text-neutral-600">Saved.</p>
</Transition>
</div>
</form>
</div>
</div>
</AppLayout>
</template>
8 changes: 8 additions & 0 deletions resources/js/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ export interface RepositoryForm {
statistics_from?: string;
}

export interface VcsInstance {
name: string;
api_url: string;
token: string | null;
installation_id: string | null;
platform: 'github' | 'gitlab';
}

export interface VcsInstanceUser {
username: string;
}
Expand Down
6 changes: 6 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\RepositoryController;
use App\Http\Controllers\UserController;
use App\Http\Controllers\VcsInstanceController;
use App\Http\Controllers\VcsInstanceUserController;
use Illuminate\Support\Facades\Route;

Expand Down Expand Up @@ -54,6 +55,11 @@
'destroy',
]);

Route::resource('vcs-instances', VcsInstanceController::class)->only([
'create',
'store',
]);

Route::resource('challenges', ChallengeController::class)->only([
'create',
'store',
Expand Down
Loading