From 85572bd258592969e09a5e251db47994346c03d5 Mon Sep 17 00:00:00 2001 From: Azmog <80716388+AzmogEx@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:43:50 +0200 Subject: [PATCH] feat: ajout du provisionnement des comptes Gitea pour les utilisateurs lors de la soumission de suggestions --- .env.example | 4 + app/Http/Controllers/SuggestionController.php | 22 ++- app/Models/Utilisateur.php | 2 + app/Services/GiteaIssueService.php | 45 ++++-- app/Services/GiteaUserService.php | 132 ++++++++++++++++++ config/services.php | 4 + ...000_add_gitea_username_to_utilisateurs.php | 24 ++++ resources/views/suggestions/create.blade.php | 20 +++ tests/Feature/SuggestionTest.php | 127 ++++++++++++++++- 9 files changed, 364 insertions(+), 16 deletions(-) create mode 100644 app/Services/GiteaUserService.php create mode 100644 database/migrations/2026_07_07_120000_add_gitea_username_to_utilisateurs.php diff --git a/.env.example b/.env.example index 862b6b2..36b210d 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,10 @@ AWS_USE_PATH_STYLE_ENDPOINT=false GITEA_URL=https://gitea.sam-coffre.duckdns.org GITEA_TOKEN= GITEA_REPO=Groupe1/Ressources-relationnel +# Token d'un ADMIN Gitea (scopes "write:admin" + "write:issue") : sert à créer un +# compte Gitea à chaque utilisateur (au 1er envoi) et à ouvrir les tickets en leur +# nom (en-tête Sudo). Laisser vide pour retomber sur GITEA_TOKEN / compte de service. +GITEA_ADMIN_TOKEN= # --- Applications mobiles (page /telecharger) --- # Laisser vide pour servir les fichiers de public/downloads/ (.apk / .ipa), diff --git a/app/Http/Controllers/SuggestionController.php b/app/Http/Controllers/SuggestionController.php index 861a51a..c1f11ab 100644 --- a/app/Http/Controllers/SuggestionController.php +++ b/app/Http/Controllers/SuggestionController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Models\Utilisateur; use App\Services\GiteaIssueService; +use App\Services\GiteaUserService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\Rule; @@ -17,7 +18,7 @@ class SuggestionController extends Controller } // Traiter la suggestion et créer un ticket dans Gitea - public function store(Request $request, GiteaIssueService $gitea) + public function store(Request $request, GiteaIssueService $gitea, GiteaUserService $giteaUsers) { $data = $request->validate([ 'type' => ['required', Rule::in(['Modification', 'Ajout', 'Correction'])], @@ -37,6 +38,10 @@ class SuggestionController extends Controller $user = Auth::user(); $auteur = $user->Pseudo ?? 'Utilisateur'; + // Compte Gitea de l'utilisateur (créé au premier envoi) pour que le + // ticket soit ouvert en son nom et qu'il puisse le suivre. + $compte = $giteaUsers->assurerCompte($user); + $title = "[{$data['type']}] {$data['titre']}"; $body = "**Type de demande :** {$data['type']}\n" @@ -46,9 +51,20 @@ class SuggestionController extends Controller ."---\n" .'_Ticket créé automatiquement depuis le formulaire de suggestions du site._'; - if ($gitea->createIssue($title, $body)) { - return redirect()->route('suggestions.create') + if ($gitea->createIssue($title, $body, $compte['username'] ?? null)) { + $redirect = redirect()->route('suggestions.create') ->with('success', 'Merci ! Votre suggestion a bien été transmise à l\'équipe.'); + + // Identifiants affichés UNE seule fois, uniquement si le compte vient d'être créé. + if ($compte !== null && $compte['motDePasseInitial'] !== null) { + $redirect->with('gitea_compte', [ + 'url' => rtrim((string) config('services.gitea.url'), '/'), + 'username' => $compte['username'], + 'mot_de_passe' => $compte['motDePasseInitial'], + ]); + } + + return $redirect; } return back()->withInput()->withErrors([ diff --git a/app/Models/Utilisateur.php b/app/Models/Utilisateur.php index 9597cea..b8ab1ae 100644 --- a/app/Models/Utilisateur.php +++ b/app/Models/Utilisateur.php @@ -11,6 +11,7 @@ use Illuminate\Support\Carbon; * @property string $Email * @property string $MotDePasse * @property string $Pseudo + * @property string|null $GiteaUsername * @property string $Statut * @property int $ID_Role * @property Carbon $DateInscription @@ -37,6 +38,7 @@ class Utilisateur extends Authenticatable 'Email', 'MotDePasse', 'Pseudo', + 'GiteaUsername', 'Statut', 'ID_Role', 'DateInscription', diff --git a/app/Services/GiteaIssueService.php b/app/Services/GiteaIssueService.php index 6de6bf8..416a1d0 100644 --- a/app/Services/GiteaIssueService.php +++ b/app/Services/GiteaIssueService.php @@ -2,6 +2,7 @@ namespace App\Services; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -10,9 +11,13 @@ class GiteaIssueService /** * Crée un ticket (issue) dans le dépôt Gitea configuré. * + * Si $sudo est fourni (username Gitea de l'utilisateur), le ticket est + * créé en son nom via l'en-tête "Sudo" (nécessite un token admin). En cas + * de refus, on retombe sur une création avec le compte de service. + * * @return bool true si le ticket a bien été créé, false sinon. */ - public function createIssue(string $title, string $body): bool + public function createIssue(string $title, string $body, ?string $sudo = null): bool { $url = rtrim((string) config('services.gitea.url'), '/'); $repo = trim((string) config('services.gitea.repo'), '/'); @@ -25,14 +30,15 @@ class GiteaIssueService } try { - // Gitea attend l'en-tête "Authorization: token ". - $response = Http::withToken($token, 'token') - ->acceptJson() - ->timeout(10) - ->post("{$url}/api/v1/repos/{$repo}/issues", [ - 'title' => $title, - 'body' => $body, + $response = $this->postIssue($url, $repo, $token, $title, $body, $sudo); + + if ($sudo !== null && $response->failed()) { + Log::warning('Création du ticket au nom de l\'utilisateur refusée, repli sur le compte de service.', [ + 'sudo' => $sudo, + 'status' => $response->status(), ]); + $response = $this->postIssue($url, $repo, $token, $title, $body, null); + } } catch (\Throwable $e) { Log::error('Appel API Gitea impossible : '.$e->getMessage()); @@ -50,4 +56,27 @@ class GiteaIssueService return true; } + + private function postIssue( + string $url, + string $repo, + string $token, + string $title, + string $body, + ?string $sudo, + ): Response { + // Gitea attend l'en-tête "Authorization: token ". + $request = Http::withToken($token, 'token') + ->acceptJson() + ->timeout(10); + + if ($sudo !== null) { + $request = $request->withHeaders(['Sudo' => $sudo]); + } + + return $request->post("{$url}/api/v1/repos/{$repo}/issues", [ + 'title' => $title, + 'body' => $body, + ]); + } } diff --git a/app/Services/GiteaUserService.php b/app/Services/GiteaUserService.php new file mode 100644 index 0000000..0bfa57e --- /dev/null +++ b/app/Services/GiteaUserService.php @@ -0,0 +1,132 @@ +GiteaUsername) { + return ['username' => $utilisateur->GiteaUsername, 'motDePasseInitial' => null]; + } + + $client = $this->client(); + + if ($client === null) { + return null; + } + + // Un compte existe peut-être déjà avec cet email (créé hors du site) : on le réutilise. + if ($existant = $this->chercherParEmail($client, $utilisateur->Email)) { + $utilisateur->GiteaUsername = $existant; + $utilisateur->save(); + + return ['username' => $existant, 'motDePasseInitial' => null]; + } + + $username = $this->genererUsername($utilisateur); + $motDePasse = Str::password(16); + + try { + $response = $client->post('/api/v1/admin/users', [ + 'username' => $username, + 'email' => $utilisateur->Email, + 'password' => $motDePasse, + 'full_name' => $utilisateur->Pseudo ?? $username, + 'must_change_password' => true, + 'send_notify' => false, + ]); + } catch (\Throwable $e) { + Log::error('Appel API Gitea (création utilisateur) impossible : '.$e->getMessage()); + + return null; + } + + if ($response->failed()) { + Log::warning('Création du compte Gitea échouée (token admin requis ?)', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return null; + } + + $utilisateur->GiteaUsername = $username; + $utilisateur->save(); + + return ['username' => $username, 'motDePasseInitial' => $motDePasse]; + } + + private function client(): ?PendingRequest + { + $url = rtrim((string) config('services.gitea.url'), '/'); + $token = (string) (config('services.gitea.admin_token') ?: config('services.gitea.token')); + + if ($url === '' || $token === '') { + Log::warning('Gitea non configuré (URL / token manquant) : compte utilisateur non créé.'); + + return null; + } + + return Http::baseUrl($url) + ->withToken($token, 'token') + ->acceptJson() + ->timeout(10); + } + + private function chercherParEmail(PendingRequest $client, string $email): ?string + { + try { + $response = $client->get('/api/v1/admin/emails/search', ['q' => $email]); + } catch (\Throwable) { + return null; + } + + if ($response->failed()) { + return null; + } + + foreach ($response->json() ?? [] as $entree) { + if (strcasecmp($entree['email'] ?? '', $email) === 0) { + return $entree['username'] ?? null; + } + } + + return null; + } + + /** + * Username déterministe et unique : pseudo en slug, suffixé de l'ID interne. + */ + private function genererUsername(Utilisateur $utilisateur): string + { + $base = Str::slug((string) ($utilisateur->Pseudo ?? '')); + + if ($base === '') { + $base = 'utilisateur'; + } + + return Str::limit($base, 30, '').'-'.$utilisateur->ID_Utilisateur; + } +} diff --git a/config/services.php b/config/services.php index d3d2fcb..771ddd0 100644 --- a/config/services.php +++ b/config/services.php @@ -37,9 +37,13 @@ return [ // Gitea : création automatique de tickets (issues) depuis le formulaire // de suggestions du site. Le token doit avoir le scope "write:issue". + // Le token admin (scopes write:admin + write:issue) sert à créer les + // comptes Gitea des utilisateurs et à ouvrir les tickets en leur nom ; + // à défaut, le token de service est utilisé. 'gitea' => [ 'url' => env('GITEA_URL', 'https://gitea.sam-coffre.duckdns.org'), 'token' => env('GITEA_TOKEN'), + 'admin_token' => env('GITEA_ADMIN_TOKEN'), 'repo' => env('GITEA_REPO', 'Groupe1/Ressources-relationnel'), ], diff --git a/database/migrations/2026_07_07_120000_add_gitea_username_to_utilisateurs.php b/database/migrations/2026_07_07_120000_add_gitea_username_to_utilisateurs.php new file mode 100644 index 0000000..c4c56c6 --- /dev/null +++ b/database/migrations/2026_07_07_120000_add_gitea_username_to_utilisateurs.php @@ -0,0 +1,24 @@ +string('GiteaUsername', 100)->nullable()->after('Pseudo'); + }); + } + + public function down(): void + { + Schema::table('utilisateurs', function (Blueprint $table) { + $table->dropColumn('GiteaUsername'); + }); + } +}; diff --git a/resources/views/suggestions/create.blade.php b/resources/views/suggestions/create.blade.php index 328ce7a..72f3b6d 100644 --- a/resources/views/suggestions/create.blade.php +++ b/resources/views/suggestions/create.blade.php @@ -21,6 +21,26 @@ @endif + + @if (session('gitea_compte')) + @php($compte = session('gitea_compte')) +
+

🎉 Un compte Gitea a été créé pour vous !

+

+ Vous pouvez maintenant suivre, commenter et créer vos tickets directement sur + {{ $compte['url'] }}. +

+

+ Identifiant : {{ $compte['username'] }}
+ Mot de passe temporaire : {{ $compte['mot_de_passe'] }} +

+

+ ⚠️ Notez ce mot de passe dès maintenant : il ne sera plus jamais affiché. + Il vous sera demandé de le changer à votre première connexion. +

+
+ @endif + @if ($errors->any())
diff --git a/tests/Feature/SuggestionTest.php b/tests/Feature/SuggestionTest.php index 6c1d454..b533bfb 100644 --- a/tests/Feature/SuggestionTest.php +++ b/tests/Feature/SuggestionTest.php @@ -31,10 +31,25 @@ class SuggestionTest extends TestCase config([ 'services.gitea.url' => 'https://gitea.test', 'services.gitea.token' => 'fake-token', + 'services.gitea.admin_token' => 'fake-admin-token', 'services.gitea.repo' => 'Groupe1/Ressources-relationnel', ]); } + /** + * Fakes par défaut : aucun compte existant, création de compte et de ticket OK. + * + * @param array $overrides + */ + private function fakesGitea(array $overrides = []): void + { + Http::fake($overrides + [ + 'gitea.test/api/v1/admin/emails/search*' => Http::response([]), + 'gitea.test/api/v1/admin/users' => Http::response(['id' => 42], 201), + 'gitea.test/api/v1/repos/*/issues' => Http::response(['number' => 42], 201), + ]); + } + public function test_le_formulaire_de_suggestion_exige_une_authentification(): void { $this->get('/suggestions/nouveau')->assertRedirect('/login'); @@ -43,9 +58,7 @@ class SuggestionTest extends TestCase public function test_une_suggestion_valide_cree_un_ticket_gitea(): void { $this->configurerGitea(); - Http::fake([ - '*/api/v1/repos/*/issues' => Http::response(['number' => 42], 201), - ]); + $this->fakesGitea(); $response = $this->actingAs($this->utilisateur())->post('/suggestions', [ 'type' => 'Correction', @@ -63,6 +76,110 @@ class SuggestionTest extends TestCase }); } + public function test_le_premier_envoi_cree_un_compte_gitea_pour_l_utilisateur(): void + { + $this->configurerGitea(); + $this->fakesGitea(); + + $utilisateur = $this->utilisateur(); + + $response = $this->actingAs($utilisateur)->post('/suggestions', [ + 'type' => 'Ajout', + 'titre' => 'Une nouvelle fonctionnalité', + 'description' => 'Description suffisamment longue de la fonctionnalité.', + ]); + + $usernameAttendu = 'jeantest-'.$utilisateur->ID_Utilisateur; + + $response->assertSessionHas('success'); + // Les identifiants sont flashés une seule fois pour affichage + $response->assertSessionHas('gitea_compte', fn ($compte) => $compte['username'] === $usernameAttendu + && $compte['mot_de_passe'] !== null); + + $this->assertSame($usernameAttendu, $utilisateur->fresh()->GiteaUsername); + + // Le compte a été créé via l'API admin… + Http::assertSent(fn ($request) => str_contains($request->url(), '/api/v1/admin/users') + && $request['username'] === $usernameAttendu + && $request['email'] === 'jean.test@example.com' + && $request['must_change_password'] === true); + + // …et le ticket est ouvert au nom de l'utilisateur (en-tête Sudo) + Http::assertSent(fn ($request) => str_contains($request->url(), '/issues') + && $request->header('Sudo') === [$usernameAttendu]); + } + + public function test_un_compte_gitea_existant_est_reutilise(): void + { + $this->configurerGitea(); + $this->fakesGitea(); + + $utilisateur = $this->utilisateur(); + $utilisateur->GiteaUsername = 'jean-deja-la'; + $utilisateur->save(); + + $response = $this->actingAs($utilisateur)->post('/suggestions', [ + 'type' => 'Modification', + 'titre' => 'Changer un contenu', + 'description' => 'Description suffisamment longue de la modification.', + ]); + + $response->assertSessionHas('success'); + $response->assertSessionMissing('gitea_compte'); + + // Aucune création de compte ne doit être tentée + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/api/v1/admin/users')); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/issues') + && $request->header('Sudo') === ['jean-deja-la']); + } + + public function test_le_ticket_est_cree_meme_si_la_creation_du_compte_echoue(): void + { + $this->configurerGitea(); + // Token sans droits admin : la création de compte est refusée + $this->fakesGitea([ + 'gitea.test/api/v1/admin/emails/search*' => Http::response('Forbidden', 403), + 'gitea.test/api/v1/admin/users' => Http::response('Forbidden', 403), + ]); + + $response = $this->actingAs($this->utilisateur())->post('/suggestions', [ + 'type' => 'Correction', + 'titre' => 'Un bug à corriger', + 'description' => 'Description suffisamment longue du bug rencontré.', + ]); + + // Repli : ticket créé avec le compte de service, sans en-tête Sudo + $response->assertSessionHas('success'); + $response->assertSessionMissing('gitea_compte'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/issues') + && $request->header('Sudo') === []); + } + + public function test_un_sudo_refuse_retombe_sur_le_compte_de_service(): void + { + $this->configurerGitea(); + $this->fakesGitea([ + // 1er appel (Sudo) refusé, 2e appel (compte de service) accepté + 'gitea.test/api/v1/repos/*/issues' => Http::sequence() + ->push('Not Found', 404) + ->push(['number' => 43], 201), + ]); + + $utilisateur = $this->utilisateur(); + $utilisateur->GiteaUsername = 'jean-deja-la'; + $utilisateur->save(); + + $response = $this->actingAs($utilisateur)->post('/suggestions', [ + 'type' => 'Ajout', + 'titre' => 'Encore une idée géniale', + 'description' => 'Description suffisamment longue de cette idée géniale.', + ]); + + $response->assertSessionHas('success'); + } + public function test_une_suggestion_invalide_est_rejetee(): void { Http::fake(); @@ -80,8 +197,8 @@ class SuggestionTest extends TestCase public function test_un_echec_gitea_affiche_une_erreur(): void { $this->configurerGitea(); - Http::fake([ - '*/api/v1/repos/*/issues' => Http::response(['message' => 'nope'], 500), + $this->fakesGitea([ + 'gitea.test/api/v1/repos/*/issues' => Http::response(['message' => 'nope'], 500), ]); $response = $this->actingAs($this->utilisateur())->post('/suggestions', [