ajoute la visio de classe : Jitsi auto-hébergé, jetons du portail, prof seul modérateur

Jitsi (docker-jitsi-meet, version figée) derrière Nginx sur meet.syllabuslab.fr,
accès par jeton JWT signé par le portail, modération du micro, mains levées
synchronisées avec l'application. Pare-feu : UDP 10000 et pont interne
br-visio (accords d'Adam). Jicofo n'authentifie plus lui-même : il donnait
le rôle de propriétaire aux élèves. Capacité ramenée à 20 VM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-12 12:35:35 +02:00
parent dd4f9c3c55
commit ec4eae3744
36 changed files with 1406 additions and 18 deletions

View file

@ -0,0 +1,230 @@
<?php
namespace App\Http\Controllers;
use App\Models\Eleve;
use App\Models\Seance;
use App\Services\Visio\JetonVisio;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* Visio de classe (étape 15). Jitsi porte le son et l'image ; le portail
* décide qui entre (jeton JWT par personne, salle imprévisible), et tient
* l'état de la classe : mains levées (premier arrivé en haut), salle
* d'attente, chat. Cet état vit dans le cache (Redis), le temps de la visio.
*/
class VisioController extends Controller
{
private const DUREE_ETAT = 6 * 3600;
// Un élève est « en salle d'attente » s'il a donné signe de vie depuis.
private const PRESENCE_SECONDES = 15;
public function __construct(private readonly JetonVisio $jetons) {}
// ---- Prof -------------------------------------------------------------
public function prof(Seance $seance): View
{
$prof = $this->verifierProf($seance);
return view('seances.visio-prof', [
'seance' => $seance->load(['classe', 'lab']),
'jeton' => $seance->visioOuverte() ? $this->jetons->pour($seance, 'prof-'.$prof->id, $prof->name, true) : null,
]);
}
public function ouvrir(Seance $seance): RedirectResponse
{
$this->verifierProf($seance);
abort_if($seance->classe->estArchivee() || $seance->statut === Seance::TERMINEE, 409);
if (! $seance->visioOuverte()) {
$seance->update([
'visio_ouverte_a' => now(),
'visio_salle' => 'syllabus-'.$seance->id.'-'.Str::lower(Str::random(16)),
'visio_chat' => false,
]);
}
return redirect()->route('seances.visio.prof', $seance);
}
public function fermer(Seance $seance): RedirectResponse
{
$this->verifierProf($seance);
$seance->update(['visio_ouverte_a' => null, 'visio_salle' => null, 'visio_chat' => false]);
Cache::forget($this->cle($seance));
return redirect()->route('seances.attente', $seance);
}
public function etatProf(Seance $seance): JsonResponse
{
$this->verifierProf($seance);
$etat = $this->etat($seance);
$limite = time() - self::PRESENCE_SECONDES;
$mains = collect($etat['mains'])->sortBy('depuis')->map(fn ($m, $id) => $m + [
'eleve_id' => $id,
'participant_id' => $etat['participants'][$id] ?? null,
])->values();
$attente = collect($etat['presences'])
->filter(fn ($p, $id) => $p['vu'] >= $limite && ! isset($etat['participants'][$id]))
->map(fn ($p) => ['nom' => $p['nom']])->values();
return response()->json([
'ouverte' => $seance->visioOuverte(),
'chat' => $seance->visio_chat,
'mains' => $mains,
'attente' => $attente,
]);
}
/**
* Le prof a donné la parole dans Jitsi (askToUnmute, qui autorise le
* micro quand la modération est active) : la main est baissée.
*/
public function parole(Seance $seance, Eleve $eleve): JsonResponse
{
$this->verifierProf($seance);
abort_if($eleve->classe_id !== $seance->classe_id, 404);
$this->modifier($seance, function (array $etat) use ($eleve) {
unset($etat['mains'][$eleve->id]);
return $etat;
});
return response()->json(['ok' => true]);
}
public function chat(Request $request, Seance $seance): JsonResponse
{
$this->verifierProf($seance);
$seance->update(['visio_chat' => $request->boolean('actif')]);
return response()->json(['chat' => $seance->visio_chat]);
}
// ---- Élève ------------------------------------------------------------
public function eleve(Seance $seance): View
{
$eleve = $this->verifierEleve($seance);
return view('seances.visio-eleve', [
'seance' => $seance->load(['classe', 'lab']),
'eleve' => $eleve,
'jeton' => $seance->visioOuverte() ? $this->jetons->pour($seance, 'eleve-'.$eleve->id, $this->nomVisible($eleve), false) : null,
]);
}
/**
* Interrogé régulièrement par la page de l'élève : sert aussi de signe de
* présence (salle d'attente côté prof).
*/
public function etatEleve(Seance $seance): JsonResponse
{
$eleve = $this->verifierEleve($seance);
$etat = $this->modifier($seance, function (array $etat) use ($eleve) {
$etat['presences'][$eleve->id] = ['nom' => $this->nomVisible($eleve), 'vu' => time()];
return $etat;
});
return response()->json([
'ouverte' => $seance->visioOuverte(),
'chat' => $seance->visio_chat,
'main_levee' => isset($etat['mains'][$eleve->id]),
]);
}
public function main(Request $request, Seance $seance): JsonResponse
{
$eleve = $this->verifierEleve($seance);
$levee = $request->boolean('levee');
$this->modifier($seance, function (array $etat) use ($eleve, $levee) {
if ($levee) {
$etat['mains'][$eleve->id] ??= ['nom' => $this->nomVisible($eleve), 'depuis' => time()];
} else {
unset($etat['mains'][$eleve->id]);
}
return $etat;
});
return response()->json(['main_levee' => $levee]);
}
public function participant(Request $request, Seance $seance): JsonResponse
{
$eleve = $this->verifierEleve($seance);
$participant = $request->validate(['participant_id' => ['required', 'string', 'max:64', 'regex:/^[A-Za-z0-9_-]+$/']])['participant_id'];
$this->modifier($seance, function (array $etat) use ($eleve, $participant) {
$etat['participants'][$eleve->id] = $participant;
return $etat;
});
return response()->json(['ok' => true]);
}
// ---- Interne ----------------------------------------------------------
private function verifierProf(Seance $seance)
{
$prof = Auth::guard('web')->user();
abort_if($seance->classe->user_id !== $prof->id, 403);
return $prof;
}
private function verifierEleve(Seance $seance): Eleve
{
$eleve = Auth::guard('eleve')->user();
abort_if($eleve->classe_id !== $seance->classe_id || $seance->classe->estArchivee(), 403);
return $eleve;
}
/**
* Minimisation : visible de toute la classe, donc prénom et initiale du nom.
*/
private function nomVisible(Eleve $eleve): string
{
return $eleve->prenom.($eleve->nom ? ' '.mb_strtoupper(mb_substr($eleve->nom, 0, 1)).'.' : '');
}
private function cle(Seance $seance): string
{
return "visio:{$seance->id}";
}
/**
* @return array{mains: array<int, array{nom: string, depuis: int}>, participants: array<int, string>, presences: array<int, array{nom: string, vu: int}>}
*/
private function etat(Seance $seance): array
{
return Cache::get($this->cle($seance)) ?? ['mains' => [], 'participants' => [], 'presences' => []];
}
/**
* Lecture-modification-écriture sous verrou : deux élèves qui lèvent la
* main au même instant ne s'écrasent pas.
*/
private function modifier(Seance $seance, callable $changement): array
{
return Cache::lock($this->cle($seance).':verrou', 5)->block(3, function () use ($seance, $changement) {
$etat = $changement($this->etat($seance));
Cache::put($this->cle($seance), $etat, self::DUREE_ETAT);
return $etat;
});
}
}

View file

@ -50,9 +50,13 @@ class PolitiqueSecuriteContenu
'report-uri' => '/csp-rapport',
];
// Visio (étape 15) : script d'intégration et cadre servis par Jitsi.
$visio = config('visio.domaine') ? ' https://'.config('visio.domaine') : '';
$directives = $this->estApplication($request)
? [
'script-src' => "'self' 'nonce-".Vite::cspNonce()."'",
'script-src' => "'self' 'nonce-".Vite::cspNonce()."'".$visio,
'frame-src' => "'self'".$visio,
// Attributs style= présents dans le gabarit commun : sans
// risque d'exécution de code, tolérés.
'style-src' => "'self' 'unsafe-inline'",

View file

@ -19,14 +19,21 @@ class Seance extends Model
public const TERMINEE = 'terminee';
protected $fillable = ['classe_id', 'lab_id', 'statut', 'prevue_a', 'demarree_a', 'terminee_a'];
protected $fillable = ['classe_id', 'lab_id', 'statut', 'prevue_a', 'demarree_a', 'terminee_a', 'visio_ouverte_a', 'visio_salle', 'visio_chat'];
protected $casts = [
'prevue_a' => 'datetime',
'demarree_a' => 'datetime',
'terminee_a' => 'datetime',
'visio_ouverte_a' => 'datetime',
'visio_chat' => 'boolean',
];
public function visioOuverte(): bool
{
return $this->visio_ouverte_a !== null && $this->visio_salle !== null;
}
public function classe(): BelongsTo
{
return $this->belongsTo(Classe::class);

View file

@ -0,0 +1,52 @@
<?php
namespace App\Services\Visio;
use App\Models\Seance;
use RuntimeException;
/**
* Jeton JWT (HS256) qui ouvre une salle Jitsi : Prosody le vérifie avec le
* même secret. Le nom vient du jeton et ne se modifie pas ; seul le prof a
* moderator = true (module natif token_affiliation de Prosody).
*/
class JetonVisio
{
public function pour(Seance $seance, string $identifiant, string $nom, bool $moderateur): string
{
$secret = config('visio.jwt.secret');
if (! is_string($secret) || strlen($secret) < 32 || $seance->visio_salle === null) {
throw new RuntimeException('Visio non configurée ou salle non ouverte.');
}
$maintenant = time();
$charge = [
'iss' => config('visio.jwt.app_id'),
'aud' => config('visio.jwt.audience'),
'sub' => '*',
'room' => $seance->visio_salle,
'iat' => $maintenant,
'nbf' => $maintenant - 10,
'exp' => $maintenant + 60 * (int) config('visio.jwt.duree_minutes'),
'context' => [
'user' => [
'id' => $identifiant,
'name' => $nom,
'moderator' => $moderateur,
],
],
];
$entete = $this->base64Url(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$corps = $this->base64Url(json_encode($charge, JSON_UNESCAPED_UNICODE));
$signature = $this->base64Url(hash_hmac('sha256', "{$entete}.{$corps}", $secret, true));
return "{$entete}.{$corps}.{$signature}";
}
private function base64Url(string $donnees): string
{
return rtrim(strtr(base64_encode($donnees), '+/', '-_'), '=');
}
}