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:
parent
dd4f9c3c55
commit
ec4eae3744
36 changed files with 1406 additions and 18 deletions
230
portail/app/Http/Controllers/VisioController.php
Normal file
230
portail/app/Http/Controllers/VisioController.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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'",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue