ajoute l'API publique /api/v1 (bloc B, premier chantier)

Catalogue en lecture seule (labs, banque de quiz officielle, cours) -
choix d'Adam pour la première version, zéro donnée d'élève. Laravel
Sanctum, un jeton Bearer par développeur créé depuis /jetons-api (un
prof), portée catalogue:lire seulement. Quota par jeton (pas par
compte ni par IP) dans Redis via un limiteur nommé, 60 requêtes/minute.
Documentation : spécification OpenAPI 3.0 et page publique /api/docs.

Désactive /sanctum/csrf-cookie (config/sanctum.php) : l'API est en
jeton Bearer seulement, jamais en cookie de SPA.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-14 01:23:23 +02:00
parent 3b961209f5
commit ecf3dfb21d
22 changed files with 874 additions and 2 deletions

View file

@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\V1\CoursResource;
use App\Models\Cours;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* API publique v1 (bloc B) : catalogue des cours, en lecture seule.
*/
class CoursController extends Controller
{
public function index(): AnonymousResourceCollection
{
return CoursResource::collection(Cours::orderBy('identifiant')->get());
}
public function show(Cours $cours): CoursResource
{
return new CoursResource($cours);
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\V1\LabResource;
use App\Models\Lab;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* API publique v1 (bloc B) : catalogue des labs, en lecture seule.
*/
class LabController extends Controller
{
public function index(): AnonymousResourceCollection
{
return LabResource::collection(Lab::orderBy('identifiant')->get());
}
public function show(Lab $lab): LabResource
{
return new LabResource($lab);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\V1\QuizResource;
use App\Models\Quiz;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* API publique v1 (bloc B) : catalogue de la banque de quiz officielle,
* en lecture seule. Seulement les quiz officiels (avec un thème) : les
* quiz partagés entre profs restent internes à l'application.
*/
class QuizController extends Controller
{
public function index(): AnonymousResourceCollection
{
return QuizResource::collection(
Quiz::where('partage', true)->whereNotNull('theme')->withCount('questions')->orderBy('theme')->get()
);
}
public function show(Quiz $quiz): QuizResource
{
abort_unless($quiz->partage && $quiz->theme !== null, 404);
return new QuizResource($quiz->loadCount('questions'));
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
use Laravel\Sanctum\PersonalAccessToken;
/**
* Jetons de l'API publique (bloc B, CLAUDE.md) : un prof en crée pour un
* usage développeur (le sien ou celui d'un tiers à qui il le transmet).
* Le jeton en clair n'est montré qu'une fois à la création — même règle
* que le code secret d'un élève (ClasseController).
*/
class TokenApiController extends Controller
{
public function index(): View
{
$prof = Auth::guard('web')->user();
return view('jetons-api', ['jetons' => $prof->tokens()->latest()->get()]);
}
public function store(Request $request): RedirectResponse
{
$donnees = $request->validate(['nom' => ['required', 'string', 'max:80']]);
$prof = Auth::guard('web')->user();
$jeton = $prof->createToken($donnees['nom'], ['catalogue:lire']);
return redirect()->route('jetons-api.index')->with('jeton_cree', $jeton->plainTextToken);
}
public function destroy(PersonalAccessToken $jeton): RedirectResponse
{
$prof = Auth::guard('web')->user();
abort_if($jeton->tokenable_id !== $prof->id || $jeton->tokenable_type !== $prof::class, 403);
$jeton->delete();
return redirect()->route('jetons-api.index');
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources\Api\V1;
use App\Models\Cours;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Cours */
class CoursResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'identifiant' => $this->identifiant,
'titre' => $this->titre,
'contenu_markdown' => $this->when($request->routeIs('api.v1.cours.show'), $this->contenu_markdown),
];
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources\Api\V1;
use App\Models\Lab;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Lab */
class LabResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'identifiant' => $this->identifiant,
'titre' => $this->titre,
'niveau' => $this->niveau,
'duree_min' => $this->duree_min,
'competences' => $this->competences,
];
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Resources\Api\V1;
use App\Models\Quiz;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Catalogue seulement : titre, thème, nombre de questions — jamais les
* questions ni les réponses (banque officielle, lecture publique).
*
* @mixin Quiz
*/
class QuizResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'titre' => $this->titre,
'theme' => $this->theme,
'questions_count' => $this->whenCounted('questions'),
];
}
}

View file

@ -13,13 +13,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['name', 'email', 'password', 'etablissement_id'])]
#[Hidden(['password', 'remember_token', 'jeton_session_actif'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use GerePreferences, GereSessionUnique, HasFactory, Notifiable;
use GerePreferences, GereSessionUnique, HasApiTokens, HasFactory, Notifiable;
/**
* Get the attributes that should be cast.

View file

@ -5,7 +5,10 @@ namespace App\Providers;
use App\Models\Eleve;
use App\Models\User;
use App\Services\CompteurDevoirsEnAttente;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
@ -39,5 +42,13 @@ class AppServiceProvider extends ServiceProvider
'badgeMesDevoirs' => $compte instanceof Eleve ? CompteurDevoirsEnAttente::pour($compte) : 0,
]);
});
// API publique (bloc B) : quota par jeton Sanctum, pas par compte ni
// par IP — un même prof avec deux jetons a deux quotas séparés.
// CACHE_STORE=redis en production (voir CLAUDE.md : « Redis pour les
// quotas »), stocké dans config/cache.php -> 'database' en local/test.
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(60)->by(
$request->user()?->currentAccessToken()?->id ?? $request->ip()
));
}
}

View file

@ -7,10 +7,13 @@ use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
@ -18,6 +21,8 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([
'session.unique' => VerifieSessionUnique::class,
'maj.activite' => MetAJourActivite::class,
'abilities' => CheckAbilities::class,
'ability' => CheckForAnyAbility::class,
]);
$middleware->web(append: [PolitiqueSecuriteContenu::class]);
})

View file

@ -10,6 +10,7 @@
"bacon/bacon-qr-code": "^3.1",
"laravel/framework": "^13.17",
"laravel/horizon": "^5.50",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0",
"symfony/yaml": "^7.4"
},

65
portail/composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "0d410ce6600351e37dc21a4c2f27fe2b",
"content-hash": "4f63b39390b44505487b3c5999569f85",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -1532,6 +1532,69 @@
},
"time": "2026-10-06T02:25:49+00:00"
},
{
"name": "laravel/sanctum",
"version": "v4.3.3",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
"reference": "fee27a573d1a013af3721d86153a65e0b11927e6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6",
"reference": "fee27a573d1a013af3721d86153a65e0b11927e6",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/contracts": "^11.0|^12.0|^13.0",
"illuminate/database": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"php": "^8.2",
"symfony/console": "^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sanctum\\SanctumServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sanctum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
"keywords": [
"auth",
"laravel",
"sanctum"
],
"support": {
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
"time": "2026-06-23T18:26:55+00:00"
},
{
"name": "laravel/sentinel",
"version": "v1.1.0",

100
portail/config/sanctum.php Normal file
View file

@ -0,0 +1,100 @@
<?php
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Routes Sanctum (bloc B, 2026-10-14)
|--------------------------------------------------------------------------
|
| Pas de front SPA ici : l'API publique est en jeton Bearer seulement,
| jamais en cookie. La route /sanctum/csrf-cookie ne sert à rien dans
| ce cas et n'a pas à être publique.
|
*/
'routes' => false,
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => AuthenticateSession::class,
'encrypt_cookies' => EncryptCookies::class,
'validate_csrf_token' => ValidateCsrfToken::class,
],
];

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View file

@ -0,0 +1,175 @@
openapi: 3.0.3
info:
title: Syllabus — API publique
version: "1.0"
description: >
Catalogue en lecture seule (labs, banque de quiz officielle, cours).
Aucune donnée d'élève. Jeton créé depuis /jetons-api (compte prof).
contact:
email: adam@belkacemi.me
servers:
- url: https://syllabuslab.fr/api/v1
security:
- bearerAuth: []
paths:
/labs:
get:
summary: Liste des labs du catalogue
operationId: listerLabs
tags: [Labs]
responses:
"200":
description: Liste des labs
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/Lab"
/labs/{identifiant}:
get:
summary: Détail d'un lab
operationId: lireLab
tags: [Labs]
parameters:
- $ref: "#/components/parameters/IdentifiantLab"
responses:
"200":
description: Le lab
content:
application/json:
schema:
type: object
properties:
data:
$ref: "#/components/schemas/Lab"
"404":
description: Lab introuvable
/quiz:
get:
summary: Liste des quiz de la banque officielle
operationId: listerQuiz
tags: [Quiz]
responses:
"200":
description: Liste des quiz (métadonnées seulement, jamais les questions)
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/Quiz"
/quiz/{id}:
get:
summary: Détail d'un quiz (métadonnées)
operationId: lireQuiz
tags: [Quiz]
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"200":
description: Le quiz
content:
application/json:
schema:
type: object
properties:
data:
$ref: "#/components/schemas/Quiz"
"404":
description: Quiz introuvable ou non partagé
/cours:
get:
summary: Liste des cours du catalogue
operationId: listerCours
tags: [Cours]
responses:
"200":
description: Liste des cours (sans le contenu complet)
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/CoursResume"
/cours/{identifiant}:
get:
summary: Détail d'un cours, avec son contenu
operationId: lireCours
tags: [Cours]
parameters:
- name: identifiant
in: path
required: true
schema:
type: string
example: 08-deux-lan-routeur
responses:
"200":
description: Le cours
content:
application/json:
schema:
type: object
properties:
data:
$ref: "#/components/schemas/Cours"
"404":
description: Cours introuvable
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: Jeton créé depuis /jetons-api. 60 requêtes par minute et par jeton.
parameters:
IdentifiantLab:
name: identifiant
in: path
required: true
schema:
type: string
example: 08-deux-lan-routeur
schemas:
Lab:
type: object
properties:
identifiant: { type: string, example: 08-deux-lan-routeur }
titre: { type: string, example: Deux LAN et un routeur }
niveau: { type: string, nullable: true, example: decouverte }
duree_min: { type: integer, nullable: true, example: 45 }
competences:
type: array
items: { type: string }
example: [adressage-ipv4, routage-statique]
Quiz:
type: object
properties:
id: { type: integer, example: 7 }
titre: { type: string, example: Un serveur web et son client }
theme: { type: string, nullable: true, example: SRV }
questions_count: { type: integer, example: 6 }
CoursResume:
type: object
properties:
identifiant: { type: string, example: 08-deux-lan-routeur }
titre: { type: string, example: Deux LAN et un routeur }
Cours:
allOf:
- $ref: "#/components/schemas/CoursResume"
- type: object
properties:
contenu_markdown: { type: string }

View file

@ -0,0 +1,64 @@
@extends('layouts.vitrine')
@section('titre', "API publique")
@section('contenu')
<div class="mx-auto max-w-4xl px-5 py-14 lg:px-10">
<h1 class="text-4xl font-extrabold tracking-tight">API publique Syllabus</h1>
<p class="mt-4 text-lg font-semibold">Catalogue en lecture seule : labs, banque de quiz officielle, cours. Aucune donnée d'élève.</p>
<section class="mt-8 rounded-2xl border border-[#e3e5e8] bg-white p-6">
<h2 class="text-2xl font-extrabold">Authentification</h2>
<p class="mt-2 font-semibold">Crée un jeton depuis un compte prof : <a href="{{ route('login') }}" class="font-bold underline underline-offset-4">connexion</a> puis « Mon profil → Jetons de l'API ». Envoie-le dans l'en-tête :</p>
<pre class="mt-3 overflow-x-auto rounded-xl bg-[#13233A] p-4 font-mono text-sm text-white">Authorization: Bearer &lt;ton-jeton&gt;</pre>
<p class="mt-3 font-semibold">60 requêtes par minute et par jeton (en-têtes <code class="font-mono">X-RateLimit-*</code> sur chaque réponse).</p>
</section>
<section class="mt-8 rounded-2xl border border-[#e3e5e8] bg-white p-6">
<h2 class="text-2xl font-extrabold">Points d'accès</h2>
<div class="mt-4 grid gap-4">
<div>
<p class="font-mono font-bold">GET /api/v1/labs</p>
<p class="font-semibold">Liste des labs du catalogue.</p>
</div>
<div>
<p class="font-mono font-bold">GET /api/v1/labs/{identifiant}</p>
<p class="font-semibold">Détail d'un lab (ex. <code class="font-mono">08-deux-lan-routeur</code>).</p>
</div>
<div>
<p class="font-mono font-bold">GET /api/v1/quiz</p>
<p class="font-semibold">Liste des quiz de la banque officielle (titre, thème, nombre de questions — jamais les questions ni les réponses).</p>
</div>
<div>
<p class="font-mono font-bold">GET /api/v1/quiz/{id}</p>
<p class="font-semibold">Détail d'un quiz (métadonnées).</p>
</div>
<div>
<p class="font-mono font-bold">GET /api/v1/cours</p>
<p class="font-semibold">Liste des cours du catalogue.</p>
</div>
<div>
<p class="font-mono font-bold">GET /api/v1/cours/{identifiant}</p>
<p class="font-semibold">Détail d'un cours, avec son contenu complet.</p>
</div>
</div>
</section>
<section class="mt-8 rounded-2xl border border-[#e3e5e8] bg-white p-6">
<h2 class="text-2xl font-extrabold">Exemple</h2>
<pre class="mt-3 overflow-x-auto rounded-xl bg-[#13233A] p-4 font-mono text-sm text-white">curl -H "Authorization: Bearer &lt;ton-jeton&gt;" \
https://syllabuslab.fr/api/v1/labs/08-deux-lan-routeur</pre>
<pre class="mt-3 overflow-x-auto rounded-xl border border-[#e3e5e8] bg-[#f8f9fa] p-4 font-mono text-sm">{
"data": {
"identifiant": "08-deux-lan-routeur",
"titre": "Deux LAN et un routeur",
"niveau": "decouverte",
"duree_min": 45,
"competences": ["adressage-ipv4", "routage-statique"]
}
}</pre>
</section>
<p class="mt-8 font-semibold"><a href="{{ asset('openapi/v1.yaml') }}" class="font-bold underline underline-offset-4">Spécification OpenAPI (v1.yaml) →</a></p>
</div>
@endsection

View file

@ -0,0 +1,48 @@
@extends('layouts.app')
@section('titre', 'Jetons de l\'API')
@section('contenu')
<div class="mx-auto max-w-2xl px-5 py-10">
<p class="font-mono text-sm font-semibold"><a href="{{ route('profil') }}" class="underline" style="color:var(--sy-muted)">← Mon profil</a></p>
<h1 class="mt-2 text-3xl font-extrabold" style="color:var(--sy-ink)">Jetons de l'API publique</h1>
<p class="mt-2 font-semibold" style="color:var(--sy-muted)">Un jeton donne accès en lecture au catalogue (labs, banque de quiz officielle, cours) via <code class="font-mono">/api/v1</code>. 60 requêtes par minute et par jeton.</p>
@if (session('jeton_cree'))
<div class="mt-6 rounded-2xl border p-5" style="border-color:#A63D2A;background:#fce8e6">
<p class="font-extrabold" style="color:var(--sy-ink)">Ton jeton (copie-le maintenant, il ne sera plus jamais affiché) :</p>
<p class="mt-2 break-all rounded-xl p-3 font-mono text-sm font-bold" style="background:var(--sy-surface);color:var(--sy-ink)">{{ session('jeton_cree') }}</p>
<p class="mt-2 text-sm font-semibold" style="color:var(--sy-muted)">À utiliser dans l'en-tête <code class="font-mono">Authorization: Bearer &lt;jeton&gt;</code>.</p>
</div>
@endif
<form action="{{ route('jetons-api.store') }}" method="post" class="mt-6 flex flex-wrap items-end gap-3 rounded-2xl border p-5" style="border-color:var(--sy-line);background:var(--sy-surface)">
@csrf
<label class="grid gap-1 font-bold" style="color:var(--sy-ink)">Nom du jeton<input name="nom" required maxlength="80" placeholder="Ex. : script de révision" class="rounded-xl border px-3 py-2 font-semibold" style="border-color:var(--sy-line);background:var(--sy-bg);color:var(--sy-ink)"></label>
<button type="submit" class="inline-flex h-11 items-center rounded-full px-5 font-bold text-white" style="background:var(--sy-blue)">Créer un jeton</button>
</form>
<section class="mt-8">
<h2 class="text-xl font-extrabold" style="color:var(--sy-ink)">Jetons actifs</h2>
@if ($jetons->isEmpty())
<p class="mt-3 font-semibold" style="color:var(--sy-muted)">Aucun jeton pour l'instant.</p>
@else
<ul class="mt-3 grid gap-2">
@foreach ($jetons as $jeton)
<li class="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-3" style="border-color:var(--sy-line);background:var(--sy-surface)">
<span class="font-bold" style="color:var(--sy-ink)">{{ $jeton->name }}
<span class="font-mono text-xs font-semibold" style="color:var(--sy-muted)">· créé le {{ $jeton->created_at->translatedFormat('d/m/Y') }}@if($jeton->last_used_at) · utilisé le {{ $jeton->last_used_at->translatedFormat('d/m/Y') }}@endif</span>
</span>
<form action="{{ route('jetons-api.destroy', $jeton) }}" method="post" onsubmit="return confirm('Révoquer ce jeton ? Il arrêtera de fonctionner immédiatement.')">
@csrf
<button type="submit" class="font-bold underline" style="color:#A63D2A">Révoquer</button>
</form>
</li>
@endforeach
</ul>
@endif
</section>
<p class="mt-8 text-sm font-semibold" style="color:var(--sy-muted)">Documentation : <a href="{{ route('api.docs') }}" class="underline" style="color:var(--sy-blue)">/api/docs</a>.</p>
</div>
@endsection

View file

@ -39,5 +39,9 @@
</div>
</dl>
</div>
@if ($role === 'prof')
<p class="mt-6"><a href="{{ route('jetons-api.index') }}" class="font-bold underline" style="color:var(--sy-blue)">Jetons de l'API publique →</a></p>
@endif
</div>
@endsection

24
portail/routes/api.php Normal file
View file

@ -0,0 +1,24 @@
<?php
use App\Http\Controllers\Api\V1\CoursController;
use App\Http\Controllers\Api\V1\LabController;
use App\Http\Controllers\Api\V1\QuizController;
use Illuminate\Support\Facades\Route;
/**
* API publique (bloc B, CLAUDE.md) : un jeton Sanctum par développeur
* (créé depuis /jetons-api par un prof, voir TokenApiController), quota
* par jeton dans Redis (limiteur nommé « api », AppServiceProvider).
* V1 : catalogue en lecture seule (labs, banque de quiz officielle,
* cours) — pas de donnée d'élève.
*/
Route::prefix('v1')->middleware(['auth:sanctum', 'throttle:api', 'abilities:catalogue:lire'])->name('api.v1.')->group(function () {
Route::get('/labs', [LabController::class, 'index'])->name('labs.index');
Route::get('/labs/{lab:identifiant}', [LabController::class, 'show'])->name('labs.show');
Route::get('/quiz', [QuizController::class, 'index'])->name('quiz.index');
Route::get('/quiz/{quiz}', [QuizController::class, 'show'])->name('quiz.show');
Route::get('/cours', [CoursController::class, 'index'])->name('cours.index');
Route::get('/cours/{cours:identifiant}', [CoursController::class, 'show'])->name('cours.show');
});

View file

@ -34,6 +34,7 @@ use App\Http\Controllers\SeanceController;
use App\Http\Controllers\SeancesEnDirectController;
use App\Http\Controllers\SessionQuizController;
use App\Http\Controllers\SignalementController;
use App\Http\Controllers\TokenApiController;
use App\Http\Controllers\VisioController;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Support\Facades\Route;
@ -82,6 +83,7 @@ Route::view('/sujets/creer', 'sujet-creer')->name('sujet.creer');
Route::view('/messagerie', 'messagerie')->name('messagerie');
Route::view('/exports', 'export-ent')->name('export.ent');
Route::view('/aide', 'aide')->name('aide');
Route::view('/api/docs', 'api-docs')->name('api.docs');
Route::view('/academie', 'academie')->name('academie');
Route::view('/paiement', 'paiement')->name('paiement');
Route::view('/paiement/confirme', 'paiement-ok')->name('paiement.ok');
@ -219,4 +221,9 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () {
Route::get('/chat', [ChatProfController::class, 'index'])->name('chat.prof.index');
Route::get('/chat/{fil}/etat', [ChatProfController::class, 'etat'])->name('chat.prof.etat');
Route::post('/chat/{fil}/envoyer', [ChatProfController::class, 'envoyer'])->middleware('throttle:30,1')->name('chat.prof.envoyer');
// API publique (bloc B) : jetons Sanctum du prof.
Route::get('/jetons-api', [TokenApiController::class, 'index'])->name('jetons-api.index');
Route::post('/jetons-api', [TokenApiController::class, 'store'])->name('jetons-api.store');
Route::post('/jetons-api/{jeton}/revoquer', [TokenApiController::class, 'destroy'])->name('jetons-api.destroy');
});

View file

@ -0,0 +1,113 @@
<?php
use App\Models\Classe;
use App\Models\Cours;
use App\Models\Lab;
use App\Models\Quiz;
use App\Models\User;
function jetonApi(User $prof, array $abilities = ['catalogue:lire']): string
{
return $prof->createToken('test', $abilities)->plainTextToken;
}
test('aucune requête sans jeton', function () {
$this->getJson('/api/v1/labs')->assertUnauthorized();
});
test("un jeton sans l'ability catalogue:lire est refusé", function () {
$jeton = jetonApi(User::factory()->create(), []);
$this->withToken($jeton)->getJson('/api/v1/labs')->assertForbidden();
});
test('un jeton révoqué ne fonctionne plus', function () {
$prof = User::factory()->create();
$jeton = jetonApi($prof);
$prof->tokens()->delete();
$this->withToken($jeton)->getJson('/api/v1/labs')->assertUnauthorized();
});
test('liste et détail des labs', function () {
$jeton = jetonApi(User::factory()->create());
Lab::factory()->create(['identifiant' => '08-deux-lan-routeur', 'titre' => 'Deux LAN et un routeur', 'competences' => ['routage-statique']]);
$this->withToken($jeton)->getJson('/api/v1/labs')
->assertOk()
->assertJsonFragment(['identifiant' => '08-deux-lan-routeur']);
$this->withToken($jeton)->getJson('/api/v1/labs/08-deux-lan-routeur')
->assertOk()
->assertJson(['data' => ['identifiant' => '08-deux-lan-routeur', 'titre' => 'Deux LAN et un routeur', 'competences' => ['routage-statique']]]);
$this->withToken($jeton)->getJson('/api/v1/labs/inconnu')->assertNotFound();
});
test('la liste des quiz ne montre que la banque officielle (partagés, avec thème), jamais les questions', function () {
$jeton = jetonApi(User::factory()->create());
$classe = Classe::factory()->create();
$officiel = Quiz::create(['classe_id' => $classe->id, 'titre' => 'Officiel', 'theme' => 'NET', 'partage' => true]);
$officiel->questions()->create(['enonce' => 'Une question', 'type' => 'vrai_faux', 'ordre' => 0, 'reponse_vrai_faux' => true]);
Quiz::create(['classe_id' => $classe->id, 'titre' => 'Partagé sans thème', 'theme' => null, 'partage' => true]);
Quiz::create(['classe_id' => $classe->id, 'titre' => 'Privé', 'theme' => 'NET', 'partage' => false]);
$reponse = $this->withToken($jeton)->getJson('/api/v1/quiz')->assertOk();
$titres = collect($reponse->json('data'))->pluck('titre');
expect($titres->all())->toBe(['Officiel'])
->and($reponse->json('data.0'))->not->toHaveKey('questions')
->and($reponse->json('data.0.questions_count'))->toBe(1);
$this->withToken($jeton)->getJson("/api/v1/quiz/{$officiel->id}")->assertOk();
$prive = Quiz::where('titre', 'Privé')->first();
$this->withToken($jeton)->getJson("/api/v1/quiz/{$prive->id}")->assertNotFound();
});
test('liste et détail des cours : le contenu complet seulement sur le détail', function () {
$jeton = jetonApi(User::factory()->create());
Cours::create(['identifiant' => '08-deux-lan-routeur', 'titre' => 'Deux LAN et un routeur', 'contenu_markdown' => '# Cours']);
$this->withToken($jeton)->getJson('/api/v1/cours')
->assertOk()
->assertJsonMissing(['contenu_markdown' => '# Cours']);
$this->withToken($jeton)->getJson('/api/v1/cours/08-deux-lan-routeur')
->assertOk()
->assertJson(['data' => ['contenu_markdown' => '# Cours']]);
});
test('chaque réponse porte les en-têtes de limite de débit', function () {
$jeton = jetonApi(User::factory()->create());
$this->withToken($jeton)->getJson('/api/v1/labs')
->assertHeader('X-RateLimit-Limit', 60);
});
test('un prof crée un jeton, le voit en clair une seule fois, le révoque', function () {
$prof = User::factory()->create();
$page = prof($this, $prof)->post(route('jetons-api.store'), ['nom' => 'Mon script'])->assertRedirect();
$page = prof($this, $prof)->get(route('jetons-api.index'));
$page->assertOk()->assertSee('Mon script');
expect($prof->tokens()->count())->toBe(1);
$jeton = $prof->tokens()->first();
prof($this, $prof)->get(route('jetons-api.index'))->assertDontSee($jeton->token);
prof($this, $prof)->post(route('jetons-api.destroy', $jeton))->assertRedirect();
expect($prof->tokens()->count())->toBe(0);
});
test("un prof ne peut pas révoquer le jeton d'un autre prof", function () {
$prof = User::factory()->create();
$autreProf = User::factory()->create();
jetonApi($autreProf);
$jeton = $autreProf->tokens()->first();
prof($this, $prof)->post(route('jetons-api.destroy', $jeton))->assertForbidden();
expect($autreProf->tokens()->count())->toBe(1);
});