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:
parent
3b961209f5
commit
ecf3dfb21d
22 changed files with 874 additions and 2 deletions
24
portail/app/Http/Controllers/Api/V1/CoursController.php
Normal file
24
portail/app/Http/Controllers/Api/V1/CoursController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
24
portail/app/Http/Controllers/Api/V1/LabController.php
Normal file
24
portail/app/Http/Controllers/Api/V1/LabController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
30
portail/app/Http/Controllers/Api/V1/QuizController.php
Normal file
30
portail/app/Http/Controllers/Api/V1/QuizController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
45
portail/app/Http/Controllers/TokenApiController.php
Normal file
45
portail/app/Http/Controllers/TokenApiController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
23
portail/app/Http/Resources/Api/V1/CoursResource.php
Normal file
23
portail/app/Http/Resources/Api/V1/CoursResource.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
25
portail/app/Http/Resources/Api/V1/LabResource.php
Normal file
25
portail/app/Http/Resources/Api/V1/LabResource.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
29
portail/app/Http/Resources/Api/V1/QuizResource.php
Normal file
29
portail/app/Http/Resources/Api/V1/QuizResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
65
portail/composer.lock
generated
|
|
@ -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
100
portail/config/sanctum.php
Normal 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,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
175
portail/public/openapi/v1.yaml
Normal file
175
portail/public/openapi/v1.yaml
Normal 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 }
|
||||
64
portail/resources/views/api-docs.blade.php
Normal file
64
portail/resources/views/api-docs.blade.php
Normal 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 <ton-jeton></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 <ton-jeton>" \
|
||||
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
|
||||
48
portail/resources/views/jetons-api.blade.php
Normal file
48
portail/resources/views/jetons-api.blade.php
Normal 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 <jeton></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
|
||||
|
|
@ -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
24
portail/routes/api.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
113
portail/tests/Feature/ApiV1Test.php
Normal file
113
portail/tests/Feature/ApiV1Test.php
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue