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

@ -1075,3 +1075,26 @@ Deux décisions d'Adam pour finir la liste « avant le pilote ».
**Suite** **Suite**
Les 4 points « avant le pilote » sont traités (2 résolus, 2 sciemment reportés avec l'accord d'Adam). Semaine 8 du bloc A : pilote avec une vraie classe — à organiser avec Adam (ce n'est pas une tâche de code). Les 4 points « avant le pilote » sont traités (2 résolus, 2 sciemment reportés avec l'accord d'Adam). Semaine 8 du bloc A : pilote avec une vraie classe — à organiser avec Adam (ce n'est pas une tâche de code).
## 2026-10-14 — Bloc B, premier chantier : l'API publique (/api/v1)
Le pilote (semaine 8) attend qu'Adam l'organise ; il demande de continuer en attendant. Premier chantier du bloc B (poste kDrive) listé au CLAUDE.md : une API publique. Avant de coder, j'ai demandé ce qu'elle doit exposer en premier — choix d'Adam : le catalogue en lecture seule (labs, banque de quiz officielle, cours), zéro donnée d'élève.
**Fait**
- Laravel Sanctum installé : un jeton Bearer par développeur, créé par un prof depuis `/jetons-api` (le jeton en clair n'est montré qu'une fois, même règle que le code secret d'un élève). Jeton à portée (`ability` Sanctum) `catalogue:lire` seulement — pas d'accès par défaut à autre chose si l'API grandit un jour.
- `routes/api.php`, préfixe `/v1` : `GET /labs`, `/labs/{identifiant}`, `/quiz`, `/quiz/{id}`, `/cours`, `/cours/{identifiant}`. Les quiz exposés sont seulement ceux de la banque officielle (partagés **et** avec un thème) : jamais les questions ni les réponses, seulement les métadonnées (titre, thème, nombre de questions) — un quiz partagé par un prof entre collègues reste interne à l'application.
- Quota par jeton (pas par compte ni par IP) dans Redis : un limiteur nommé `api` (`AppServiceProvider`), 60 requêtes/minute, clé = l'identifiant du jeton Sanctum. `CACHE_STORE=redis` était déjà la config de production (Horizon, sessions) : rien à ajouter côté infra.
- Documentation : spécification OpenAPI 3.0 (`public/openapi/v1.yaml`) et une page publique `/api/docs` (exemples `curl`, réponse type).
- Désactivé la route `/sanctum/csrf-cookie` (`config/sanctum.php`, `routes => false`) : elle n'a pas de sens ici, l'API est en jeton Bearer seulement, jamais en cookie de SPA — sa présence faisait d'ailleurs échouer le test qui vérifie que toute page publique répond 200 (elle répond 204, une page sans contenu, ce qui est correct mais pas un code que ce test attendait).
- 9 nouveaux tests Pest (109 au total) : pas de jeton → 401, jeton sans la bonne portée → 403, jeton révoqué → 401, contenu correct de chaque point d'accès, un quiz privé ou sans thème renvoie 404, les en-têtes de limite de débit sont présents, un prof crée/voit une fois/révoque son jeton, ne peut pas révoquer celui d'un autre prof.
**Comprendre**
Le choix qui limite le plus le risque ici : exposer seulement ce qui est déjà pensé comme public ou quasi-public dans l'application (le catalogue), jamais une donnée d'élève, même en lecture. Ça permet de vraiment montrer le savoir-faire attendu pour le poste kDrive (jeton, versionnage, quotas, documentation, tests) sans ouvrir de nouvelle surface RGPD à surveiller.
**Vérifié sur le serveur réel** (prof jetable, supprimé ensuite) : jeton créé depuis `/jetons-api`, affiché une seule fois ; `GET /api/v1/labs` sans jeton → 401, avec jeton → 200 avec les en-têtes `X-RateLimit-*` (60/59) et les vraies compétences du lab.yaml ; `/api/v1/cours` liste sans le contenu, `/api/v1/cours/{id}` avec ; `/api/v1/quiz` renvoie les 8 quiz de la banque officielle avec leur thème, jamais leurs questions ; lab inconnu → 404 ; jeton révoqué depuis la page → 401 immédiatement. `/api/docs` et `/openapi/v1.yaml` répondent. Rien touché chez Adam.
**Suite**
Prochain chantier du bloc B : cache Redis sur les lectures lourdes, avec mesure avant/après dans Grafana.

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

View file

@ -5,7 +5,10 @@ namespace App\Providers;
use App\Models\Eleve; use App\Models\Eleve;
use App\Models\User; use App\Models\User;
use App\Services\CompteurDevoirsEnAttente; use App\Services\CompteurDevoirsEnAttente;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@ -39,5 +42,13 @@ class AppServiceProvider extends ServiceProvider
'badgeMesDevoirs' => $compte instanceof Eleve ? CompteurDevoirsEnAttente::pour($compte) : 0, '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\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )
@ -18,6 +21,8 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([ $middleware->alias([
'session.unique' => VerifieSessionUnique::class, 'session.unique' => VerifieSessionUnique::class,
'maj.activite' => MetAJourActivite::class, 'maj.activite' => MetAJourActivite::class,
'abilities' => CheckAbilities::class,
'ability' => CheckForAnyAbility::class,
]); ]);
$middleware->web(append: [PolitiqueSecuriteContenu::class]); $middleware->web(append: [PolitiqueSecuriteContenu::class]);
}) })

View file

@ -10,6 +10,7 @@
"bacon/bacon-qr-code": "^3.1", "bacon/bacon-qr-code": "^3.1",
"laravel/framework": "^13.17", "laravel/framework": "^13.17",
"laravel/horizon": "^5.50", "laravel/horizon": "^5.50",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"symfony/yaml": "^7.4" "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "0d410ce6600351e37dc21a4c2f27fe2b", "content-hash": "4f63b39390b44505487b3c5999569f85",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -1532,6 +1532,69 @@
}, },
"time": "2026-10-06T02:25:49+00:00" "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", "name": "laravel/sentinel",
"version": "v1.1.0", "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> </div>
</dl> </dl>
</div> </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> </div>
@endsection @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\SeancesEnDirectController;
use App\Http\Controllers\SessionQuizController; use App\Http\Controllers\SessionQuizController;
use App\Http\Controllers\SignalementController; use App\Http\Controllers\SignalementController;
use App\Http\Controllers\TokenApiController;
use App\Http\Controllers\VisioController; use App\Http\Controllers\VisioController;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken; use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Support\Facades\Route; 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('/messagerie', 'messagerie')->name('messagerie');
Route::view('/exports', 'export-ent')->name('export.ent'); Route::view('/exports', 'export-ent')->name('export.ent');
Route::view('/aide', 'aide')->name('aide'); Route::view('/aide', 'aide')->name('aide');
Route::view('/api/docs', 'api-docs')->name('api.docs');
Route::view('/academie', 'academie')->name('academie'); Route::view('/academie', 'academie')->name('academie');
Route::view('/paiement', 'paiement')->name('paiement'); Route::view('/paiement', 'paiement')->name('paiement');
Route::view('/paiement/confirme', 'paiement-ok')->name('paiement.ok'); 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', [ChatProfController::class, 'index'])->name('chat.prof.index');
Route::get('/chat/{fil}/etat', [ChatProfController::class, 'etat'])->name('chat.prof.etat'); 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'); 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);
});