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'",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
52
portail/app/Services/Visio/JetonVisio.php
Normal file
52
portail/app/Services/Visio/JetonVisio.php
Normal 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), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ return [
|
|||
// même temps, sessions d'élèves et stock compris : mesuré à l'étape 13
|
||||
// (docs/capacite.md). Doit rester aligné sur la réserve mémoire de Nova
|
||||
// (kolla/config/nova/nova-compute.conf), qui refuse au-delà.
|
||||
'capacite_vm' => (int) env('LABS_CAPACITE_VM', 22),
|
||||
'capacite_vm' => (int) env('LABS_CAPACITE_VM', 20),
|
||||
|
||||
'stock' => [
|
||||
// Stacks prêtes gardées en permanence pour chaque lab du catalogue,
|
||||
|
|
|
|||
14
portail/config/visio.php
Normal file
14
portail/config/visio.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
// Étape 15 : visio de classe (Jitsi Meet auto-hébergé, ansible/roles/visio).
|
||||
// Le secret est généré sur le serveur et copié dans .env par Ansible.
|
||||
return [
|
||||
'domaine' => env('VISIO_DOMAINE'),
|
||||
'jwt' => [
|
||||
'app_id' => env('VISIO_JWT_APP_ID', 'syllabus'),
|
||||
'audience' => env('VISIO_JWT_AUDIENCE', 'syllabus-visio'),
|
||||
'secret' => env('VISIO_JWT_SECRET'),
|
||||
// Durée de validité d'un jeton : le temps d'une séance.
|
||||
'duree_minutes' => 180,
|
||||
],
|
||||
];
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('seances', function (Blueprint $table) {
|
||||
$table->timestamp('visio_ouverte_a')->nullable()->after('terminee_a');
|
||||
// Nom de salle Jitsi imprévisible : il figure dans les jetons, et
|
||||
// un jeton ne vaut que pour cette salle.
|
||||
$table->string('visio_salle', 64)->nullable()->after('visio_ouverte_a');
|
||||
$table->boolean('visio_chat')->default(false)->after('visio_salle');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('seances', function (Blueprint $table) {
|
||||
$table->dropColumn(['visio_ouverte_a', 'visio_salle', 'visio_chat']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -48,6 +48,7 @@
|
|||
<li>sessions de lab : date, état, machines virtuelles attribuées ;</li>
|
||||
<li>résultats de la vérification automatique (réussi ou non, note, message par vérification).</li>
|
||||
<li>s'il rejoint sa classe avec le code de classe : le prénom et le nom (facultatif) qu'il saisit, le temps que l'enseignant accepte ou refuse sa demande.</li>
|
||||
<li>pendant une visio de classe : le nom affiché aux autres participants (prénom et initiale du nom, non modifiable) et les mains levées. Le son et l’image transitent par notre serveur, en France, <strong>sans être enregistrés</strong>.</li>
|
||||
</ul>
|
||||
<p>Ce que l’élève tape dans la console de sa machine virtuelle n’est pas enregistré par Syllabus. Son enseignant peut suivre la console en direct, en lecture seule, pendant la séance.</p>
|
||||
<h3>Enseignants</h3>
|
||||
|
|
@ -73,6 +74,7 @@
|
|||
<li><strong>Classe et données de ses élèves</strong> : jusqu’à l’archivage de la classe par l’enseignant, puis <strong>supprimées automatiquement 90 jours après l’archivage</strong>.</li>
|
||||
<li><strong>Demande pour rejoindre une classe</strong> : supprimée dès que l’élève a reçu son identifiant, et au plus tard une heure après son envoi.</li>
|
||||
<li><strong>Compte enseignant</strong> : tant qu’il est utilisé ; supprimé sur demande.</li>
|
||||
<li><strong>Visio</strong> : rien n’est enregistré ; l’état de la séance (mains levées, présence) reste en mémoire au plus 6 heures.</li>
|
||||
<li><strong>Journaux techniques</strong> : 14 jours.</li>
|
||||
<li><strong>Sauvegardes</strong> : chiffrées, conservées au plus 5 semaines (7 sauvegardes quotidiennes et 4 hebdomadaires). Une donnée supprimée disparaît donc des sauvegardes au plus tard 5 semaines après sa suppression.</li>
|
||||
</ul>
|
||||
|
|
@ -112,7 +114,7 @@
|
|||
<h2 id="t-cookies">Cookies</h2>
|
||||
<div class="mt-6 grid gap-4 text-lg font-medium leading-relaxed">
|
||||
<p>Syllabus n’utilise que deux cookies, strictement nécessaires : <span class="font-mono">syllabus-session</span>, qui vous garde connecté, et <span class="font-mono">XSRF-TOKEN</span>, qui protège les formulaires. Aucun cookie publicitaire ni de mesure d’audience. Ils ne demandent donc pas de consentement.</p>
|
||||
<p>L’éditeur de code et la page de visio gardent aussi, dans le stockage local de votre navigateur, votre code et une préférence d’affichage. Ces informations ne nous sont pas transmises.</p>
|
||||
<p>L’éditeur de code et la page de visio gardent aussi, dans le stockage local de votre navigateur, votre code et des préférences d’affichage (y compris celles du lecteur de visio, sur <span class="font-mono">meet.syllabuslab.fr</span>). Ces informations ne nous sont pas transmises.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@
|
|||
@csrf
|
||||
<button type="submit" class="inline-flex h-11 items-center rounded-full bg-black px-5 font-bold text-white">Rejoindre la séance</button>
|
||||
</form>
|
||||
@if ($seance->visioOuverte())
|
||||
<a href="{{ route('seances.visio.eleve', $seance) }}" class="inline-flex h-11 items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">Rejoindre la visio</a>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
@section('contenu')
|
||||
<div class="mx-auto max-w-5xl px-5 py-14 lg:px-10">
|
||||
<p class="font-mono text-sm font-semibold">{{ $seance->lab->titre }}</p>
|
||||
@if ($seance->visioOuverte())
|
||||
<p class="mt-3"><a href="{{ route('seances.visio.eleve', $seance) }}" class="inline-flex h-11 items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">Rejoindre la visio de la classe</a></p>
|
||||
@endif
|
||||
@if ($labSession->etat === $etats::EN_ATTENTE)
|
||||
<div class="py-20 text-center">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight">En attente que le prof t'admette…</h1>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
<p class="font-mono text-sm font-semibold">{{ $seance->classe->nom }} · {{ $seance->lab->titre }}</p>
|
||||
<h1 class="mt-1 text-4xl font-extrabold tracking-tight">Salle d'attente</h1>
|
||||
<p class="mt-2 font-semibold">La page se rafraîchit toutes les 10 secondes.</p>
|
||||
<p class="mt-4"><a href="{{ route('seances.visio.prof', $seance) }}" class="inline-flex h-11 items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">{{ $seance->visioOuverte() ? 'Retourner à la visio' : 'Visio de la classe' }}</a></p>
|
||||
|
||||
@if ($seance->labSessions->isEmpty())
|
||||
<p class="mt-8 font-semibold">Personne n'a encore rejoint la séance.</p>
|
||||
|
|
|
|||
100
portail/resources/views/seances/visio-eleve.blade.php
Normal file
100
portail/resources/views/seances/visio-eleve.blade.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
@extends('layouts.vitrine')
|
||||
|
||||
@section('titre', 'Visio · '.$seance->classe->nom)
|
||||
|
||||
{{-- Étape 15 : reprend design/resources/views/visio.blade.php (vue élève) ;
|
||||
l'image factice est remplacée par la vraie visio (Jitsi, IFrame API). --}}
|
||||
@section('contenu')
|
||||
<main style="background:#f8f9fa">
|
||||
<section style="padding:20px 20px 72px"><div style="max-width:1100px;margin:0 auto;background:#fff;border:1px solid #e3e5e8;border-radius:20px;overflow:hidden">
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid #e3e5e8"><span style="font-size:14px;font-weight:700;color:#0b1220">{{ $seance->classe->nom }} · {{ $seance->lab->titre }}</span><span data-statut-e style="display:inline-flex;align-items:center;gap:8px;height:32px;padding:0 14px;border-radius:999px;font-size:14px;font-weight:700;background:#eef0f3;color:#3c4043">{{ $jeton ? 'En sourdine' : 'En attente' }}</span></div>
|
||||
@if ($jeton)
|
||||
<div style="padding:20px"><div id="v-scene" style="position:relative;aspect-ratio:16/9;border-radius:16px;overflow:hidden;background:#202124"></div></div>
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:12px;padding:4px 20px 24px">
|
||||
<button type="button" data-micro-e aria-label="Micro" style="width:52px;height:52px;border-radius:50%;display:grid;place-items:center;border:1px solid #dadce0;background:#fff;cursor:pointer"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3"/></svg></button>
|
||||
<button type="button" data-cam-e aria-label="Caméra" style="width:52px;height:52px;border-radius:50%;display:grid;place-items:center;border:1px solid #dadce0;background:#fff;cursor:pointer"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg></button>
|
||||
<button type="button" data-main-e aria-pressed="false" class="border border-[#e3e5e8] rounded-2xl bg-[#fce8e6] px-5 py-4 text-left text-lg font-extrabold aria-pressed:bg-[#fef7e0]" style="display:inline-flex;align-items:center;gap:10px;cursor:pointer"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 11V6a2 2 0 0 0-4 0v5"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10.5V6a2 2 0 0 0-4 0v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg><span>Lever la main</span></button>
|
||||
<a href="{{ route('seances.eleve.attente', $seance) }}" data-quitter style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700;cursor:pointer;border-radius:999px;height:52px;padding:0 22px;background:#d93025;color:#fff;text-decoration:none"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>Quitter</a>
|
||||
</div>
|
||||
<p data-aide-e style="margin:0;padding:0 20px 24px;text-align:center;font-size:14px;font-weight:600;color:#5f6368">Tu es en sourdine. Lève la main pour demander la parole.</p>
|
||||
@else
|
||||
<p style="margin:0;padding:48px 20px;text-align:center;font-size:18px;font-weight:700;color:#3c4043">Ton prof n’a pas encore ouvert la visio. Garde cette page ouverte : tu entreras dès qu’il l’ouvrira.</p>
|
||||
@endif
|
||||
</div></section>
|
||||
</main>
|
||||
|
||||
@if ($jeton)
|
||||
<script nonce="{{ Vite::cspNonce() }}" src="https://{{ config('visio.domaine') }}/external_api.js"></script>
|
||||
@endif
|
||||
<script nonce="{{ Vite::cspNonce() }}">
|
||||
(function () {
|
||||
var URL_ETAT = @json(route('seances.visio.eleve.etat', $seance));
|
||||
var URL_MAIN = @json(route('seances.visio.eleve.main', $seance));
|
||||
var URL_PARTICIPANT = @json(route('seances.visio.eleve.participant', $seance));
|
||||
var CSRF = @json(csrf_token());
|
||||
var OUVERTE = @json((bool) $jeton);
|
||||
var $ = function (s) { return document.querySelector(s); };
|
||||
var poster = function (url, donnees) {
|
||||
return fetch(url, { method: 'POST', headers: { 'X-CSRF-TOKEN': CSRF, 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(donnees || {}) });
|
||||
};
|
||||
var api = null, moi = null, mainLevee = false, chat = null;
|
||||
var BOUTONS = ['microphone', 'camera', 'raisehand', 'tileview', 'fullscreen', 'hangup'];
|
||||
|
||||
function statut(texte, couleur, fond, aide) {
|
||||
var s = $('[data-statut-e]'); s.textContent = texte; s.style.color = couleur; s.style.background = fond;
|
||||
var a = $('[data-aide-e]'); if (a && aide) a.textContent = aide;
|
||||
}
|
||||
|
||||
@if ($jeton)
|
||||
api = new JitsiMeetExternalAPI(@json(config('visio.domaine')), {
|
||||
roomName: @json($seance->visio_salle),
|
||||
jwt: @json($jeton),
|
||||
parentNode: $('#v-scene'),
|
||||
width: '100%', height: '100%',
|
||||
configOverwrite: {
|
||||
startWithAudioMuted: true, startWithVideoMuted: true, readOnlyName: true,
|
||||
prejoinConfig: { enabled: false }, disableDeepLinking: true,
|
||||
toolbarButtons: BOUTONS.concat(@json($seance->visio_chat) ? ['chat'] : [])
|
||||
}
|
||||
});
|
||||
api.addListener('videoConferenceJoined', function (e) {
|
||||
moi = e.id;
|
||||
poster(URL_PARTICIPANT, { participant_id: e.id });
|
||||
});
|
||||
// La main levée passe toujours par Jitsi (bouton de la page ou de Jitsi) ;
|
||||
// l'application suit l'événement : une seule source, pas de désaccord.
|
||||
api.addListener('raiseHandUpdated', function (e) {
|
||||
if (e.id !== moi) return;
|
||||
mainLevee = !!e.handRaised;
|
||||
$('[data-main-e]').setAttribute('aria-pressed', String(mainLevee));
|
||||
$('[data-main-e] span').textContent = mainLevee ? 'Baisser la main' : 'Lever la main';
|
||||
poster(URL_MAIN, { levee: mainLevee });
|
||||
});
|
||||
api.addListener('audioMuteStatusChanged', function (e) {
|
||||
if (e.muted) statut('En sourdine', '#3c4043', '#eef0f3', 'Tu es en sourdine. Lève la main pour demander la parole.');
|
||||
else statut('Tu as la parole', '#0d652d', '#e6f4ea', 'Ton prof t’a donné la parole. Coupe ton micro quand tu as fini.');
|
||||
});
|
||||
api.addListener('readyToClose', function () { location.href = $('[data-quitter]').href; });
|
||||
$('[data-main-e]').addEventListener('click', function () { api.executeCommand('toggleRaiseHand'); });
|
||||
$('[data-micro-e]').addEventListener('click', function () { api.executeCommand('toggleAudio'); });
|
||||
$('[data-cam-e]').addEventListener('click', function () { api.executeCommand('toggleVideo'); });
|
||||
$('[data-quitter]').addEventListener('click', function () { try { api.executeCommand('hangup'); } catch (e) {} });
|
||||
@endif
|
||||
|
||||
function rafraichir() {
|
||||
fetch(URL_ETAT, { headers: { 'Accept': 'application/json' } }).then(function (r) { return r.json(); }).then(function (e) {
|
||||
if (e.ouverte !== OUVERTE) { location.reload(); return; }
|
||||
if (!api) return;
|
||||
// Le prof a donné la parole : l'application a baissé la main, Jitsi suit.
|
||||
if (mainLevee && !e.main_levee) api.executeCommand('toggleRaiseHand');
|
||||
if (chat !== e.chat) {
|
||||
chat = e.chat;
|
||||
api.executeCommand('overwriteConfig', { toolbarButtons: BOUTONS.concat(chat ? ['chat'] : []) });
|
||||
}
|
||||
}).catch(function () {});
|
||||
}
|
||||
rafraichir();
|
||||
setInterval(rafraichir, 2000);
|
||||
})();
|
||||
</script>
|
||||
@endsection
|
||||
138
portail/resources/views/seances/visio-prof.blade.php
Normal file
138
portail/resources/views/seances/visio-prof.blade.php
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
@extends('layouts.vitrine')
|
||||
|
||||
@section('titre', 'Visio · '.$seance->classe->nom)
|
||||
|
||||
{{-- Étape 15 : reprend design/resources/views/visio.blade.php (vue prof) ; la
|
||||
grille factice est remplacée par la vraie visio (Jitsi, IFrame API). --}}
|
||||
@section('contenu')
|
||||
<main style="background:#f8f9fa">
|
||||
<section style="padding:40px 20px 8px"><div style="max-width:1280px;margin:0 auto">
|
||||
<p style="margin:0;display:inline-flex;align-items:center;gap:8px;border:1px solid #b8c1cf;border-radius:999px;background:#fff;padding:4px 12px;font-size:14px;font-weight:700;color:#1d3fa8">Visio de classe</p>
|
||||
<h1 style="margin:14px 0 0;font-size:clamp(30px,4vw,44px);line-height:1.1;font-weight:800;letter-spacing:-0.02em;color:#0b1220">{{ $seance->classe->nom }} · {{ $seance->lab->titre }}</h1>
|
||||
<p style="margin:12px 0 0;max-width:720px;font-size:18px;line-height:1.6;font-weight:500;color:#3c4043">Tous les élèves arrivent en sourdine. Ils lèvent la main, vous donnez la parole, un à la fois.</p>
|
||||
</div></section>
|
||||
|
||||
<section style="padding:20px 20px 72px"><div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_360px]" style="max-width:1280px;margin:0 auto">
|
||||
<div style="background:#fff;border:1px solid #e3e5e8;border-radius:20px;overflow:hidden;min-width:0">
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid #e3e5e8">
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
@if ($jeton)
|
||||
<span style="display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border-radius:999px;background:#fce8e6;color:#b3261e;font-size:13px;font-weight:800"><span style="width:8px;height:8px;border-radius:50%;background:#d93025"></span>En direct · <span id="v-chrono" style="font-family:'IBM Plex Mono',monospace">00:00:00</span></span>
|
||||
@else
|
||||
<span style="display:inline-flex;align-items:center;height:28px;padding:0 10px;border-radius:999px;background:#eef0f3;color:#3c4043;font-size:13px;font-weight:800">Visio fermée</span>
|
||||
@endif
|
||||
<span style="font-size:14px;font-weight:700;color:#0b1220">{{ $seance->classe->nom }}</span><span id="v-nb" style="font-size:14px;font-weight:600;color:#5f6368"></span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
@if ($jeton)
|
||||
<button type="button" id="v-mute-all" style="display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 18px;border-radius:999px;font-size:14px;font-weight:700;cursor:pointer;border:1px solid #dadce0;background:#fff;color:#0b1220"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3M3 3l18 18"/></svg>Couper tous les micros</button>
|
||||
<form id="v-terminer" action="{{ route('seances.visio.fermer', $seance) }}" method="post" style="margin:0">@csrf<button type="submit" style="display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 18px;border-radius:999px;font-size:14px;font-weight:700;cursor:pointer;border:0;background:#d93025;color:#fff">Terminer la visio</button></form>
|
||||
@else
|
||||
<form action="{{ route('seances.visio.ouvrir', $seance) }}" method="post" style="margin:0">@csrf<button type="submit" style="display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 18px;border-radius:999px;font-size:14px;font-weight:700;cursor:pointer;border:0;background:#0b57d0;color:#fff">Ouvrir la visio</button></form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if ($jeton)
|
||||
<div id="v-scene" style="height:min(70vh,620px);background:#202124"></div>
|
||||
@else
|
||||
<p style="margin:0;padding:40px 20px;font-size:16px;font-weight:600;color:#3c4043">La visio n’est pas ouverte. Les élèves qui la rejoignent attendent dans la salle d’attente ; ouvrez-la pour les faire entrer.</p>
|
||||
@endif
|
||||
</div>
|
||||
<div style="display:grid;gap:20px;align-content:start;min-width:0">
|
||||
<section style="background:#fff;border:1px solid #e3e5e8;border-radius:20px" aria-labelledby="t-mains"><div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #e3e5e8"><h2 id="t-mains" style="margin:0;font-size:16px;font-weight:800;color:#0b1220">Mains levées</h2><span id="v-nb-mains" style="min-width:28px;height:28px;padding:0 8px;border-radius:999px;background:#fef7e0;color:#7a4b00;display:inline-grid;place-items:center;font-size:13px;font-weight:800">0</span></div><ol id="v-mains" style="margin:0;padding:8px 12px;list-style:none"></ol><p style="margin:0;padding:0 20px 14px;font-size:13px;font-weight:600;color:#5f6368">Premier arrivé en haut.</p></section>
|
||||
<section style="background:#fff;border:1px solid #e3e5e8;border-radius:20px" aria-labelledby="t-attente"><div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:16px 20px;border-bottom:1px solid #e3e5e8"><h2 id="t-attente" style="margin:0;font-size:16px;font-weight:800;color:#0b1220">Salle d’attente</h2>@unless ($jeton)<form action="{{ route('seances.visio.ouvrir', $seance) }}" method="post" style="margin:0">@csrf<button type="submit" id="v-tout-admettre" style="border:0;background:none;color:#0b57d0;font-size:14px;font-weight:700;cursor:pointer">Tout admettre</button></form>@endunless</div><ul id="v-attente" style="margin:0;padding:8px 12px;list-style:none"></ul></section>
|
||||
<section style="background:#fff;border:1px solid #e3e5e8;border-radius:20px" aria-labelledby="t-chat"><div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:16px 20px;border-bottom:1px solid #e3e5e8"><h2 id="t-chat" style="margin:0;font-size:16px;font-weight:800;color:#0b1220">Chat des élèves</h2><button type="button" id="v-chat-switch" role="switch" aria-checked="{{ $seance->visio_chat ? 'true' : 'false' }}" aria-label="Activer le chat des élèves" @disabled(! $jeton) style="position:relative;width:44px;height:26px;border-radius:999px;border:0;cursor:pointer;transition:background .2s;background:{{ $seance->visio_chat ? '#0b57d0' : '#c4c7c5' }}"><span style="position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3);transition:transform .2s;transform:translateX({{ $seance->visio_chat ? '18px' : '0' }})"></span></button></div><div id="v-chat" style="padding:16px 20px;font-size:14px;font-weight:600;color:#5f6368">{{ $seance->visio_chat ? 'Le chat est ouvert aux élèves.' : 'Le chat est désactivé.' }}</div></section>
|
||||
</div>
|
||||
</div></section>
|
||||
</main>
|
||||
|
||||
@if ($jeton)
|
||||
<script nonce="{{ Vite::cspNonce() }}" src="https://{{ config('visio.domaine') }}/external_api.js"></script>
|
||||
@endif
|
||||
<script nonce="{{ Vite::cspNonce() }}">
|
||||
(function () {
|
||||
var URL_ETAT = @json(route('seances.visio.etat', $seance));
|
||||
var URL_CHAT = @json(route('seances.visio.chat', $seance));
|
||||
var URL_PAROLE = @json(route('seances.visio.parole', [$seance, '__ELEVE__']));
|
||||
var CSRF = @json(csrf_token());
|
||||
var $ = function (s) { return document.querySelector(s); };
|
||||
var poster = function (url, donnees) {
|
||||
return fetch(url, { method: 'POST', headers: { 'X-CSRF-TOKEN': CSRF, 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(donnees || {}) });
|
||||
};
|
||||
var esc = function (t) { var d = document.createElement('span'); d.textContent = t; return d.innerHTML; };
|
||||
var api = null;
|
||||
var BOUTONS = ['microphone', 'camera', 'desktop', 'raisehand', 'participants-pane', 'tileview', 'settings', 'fullscreen', 'hangup'];
|
||||
|
||||
@if ($jeton)
|
||||
api = new JitsiMeetExternalAPI(@json(config('visio.domaine')), {
|
||||
roomName: @json($seance->visio_salle),
|
||||
jwt: @json($jeton),
|
||||
parentNode: $('#v-scene'),
|
||||
width: '100%', height: '100%',
|
||||
configOverwrite: {
|
||||
startWithAudioMuted: true, startWithVideoMuted: true, readOnlyName: true,
|
||||
prejoinConfig: { enabled: false }, disableDeepLinking: true,
|
||||
toolbarButtons: BOUTONS.concat(@json($seance->visio_chat) ? ['chat'] : [])
|
||||
}
|
||||
});
|
||||
// Seul le prof donne la parole : modération du micro dès l'arrivée.
|
||||
api.addListener('videoConferenceJoined', function () {
|
||||
api.executeCommand('toggleModeration', true, 'audio');
|
||||
});
|
||||
var compter = function () { $('#v-nb').textContent = '· ' + api.getNumberOfParticipants() + ' en ligne'; };
|
||||
api.addListener('participantJoined', compter);
|
||||
api.addListener('participantLeft', compter);
|
||||
api.addListener('readyToClose', function () { $('#v-terminer').submit(); });
|
||||
$('#v-mute-all').addEventListener('click', function () { api.executeCommand('muteEveryone', 'audio'); });
|
||||
$('#v-terminer').addEventListener('submit', function () { try { api.executeCommand('endConference'); } catch (e) {} });
|
||||
|
||||
var debut = new Date(@json($seance->visio_ouverte_a?->toIso8601String())).getTime();
|
||||
setInterval(function () {
|
||||
var s = Math.max(0, Math.floor((Date.now() - debut) / 1000));
|
||||
$('#v-chrono').textContent = [Math.floor(s / 3600), Math.floor(s / 60) % 60, s % 60].map(function (n) { return String(n).padStart(2, '0'); }).join(':');
|
||||
}, 1000);
|
||||
|
||||
$('#v-chat-switch').addEventListener('click', function () {
|
||||
var actif = this.getAttribute('aria-checked') !== 'true';
|
||||
poster(URL_CHAT, { actif: actif }).then(function () { rafraichir(); });
|
||||
});
|
||||
@endif
|
||||
|
||||
function rafraichir() {
|
||||
fetch(URL_ETAT, { headers: { 'Accept': 'application/json' } }).then(function (r) { return r.json(); }).then(function (e) {
|
||||
$('#v-nb-mains').textContent = e.mains.length;
|
||||
$('#v-mains').innerHTML = e.mains.length ? e.mains.map(function (m) {
|
||||
var attente = Math.max(0, Math.floor(Date.now() / 1000) - m.depuis);
|
||||
return '<li style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px;border-radius:12px">'
|
||||
+ '<span><strong>' + esc(m.nom) + '</strong> <span style="font-size:13px;color:#5f6368">' + (attente < 60 ? attente + ' s' : Math.floor(attente / 60) + ' min') + '</span></span>'
|
||||
+ (api && m.participant_id ? '<button type="button" data-parole="' + m.eleve_id + '" data-participant="' + esc(m.participant_id) + '" style="display:inline-flex;align-items:center;height:32px;padding:0 14px;border-radius:999px;border:0;cursor:pointer;font-size:13px;font-weight:700;background:#0b57d0;color:#fff">Donner la parole</button>' : '')
|
||||
+ '</li>';
|
||||
}).join('') : '<li style="padding:8px;font-size:14px;color:#5f6368">Aucune main levée.</li>';
|
||||
$('#v-attente').innerHTML = e.attente.length ? e.attente.map(function (a) {
|
||||
return '<li style="padding:8px;font-weight:700">' + esc(a.nom) + '</li>';
|
||||
}).join('') : '<li style="padding:8px;font-size:14px;color:#5f6368">Personne n’attend.</li>';
|
||||
var sw = $('#v-chat-switch');
|
||||
if (sw.getAttribute('aria-checked') !== String(e.chat)) {
|
||||
sw.setAttribute('aria-checked', String(e.chat));
|
||||
sw.style.background = e.chat ? '#0b57d0' : '#c4c7c5';
|
||||
sw.firstElementChild.style.transform = e.chat ? 'translateX(18px)' : 'translateX(0)';
|
||||
$('#v-chat').textContent = e.chat ? 'Le chat est ouvert aux élèves.' : 'Le chat est désactivé.';
|
||||
if (api) api.executeCommand('overwriteConfig', { toolbarButtons: BOUTONS.concat(e.chat ? ['chat'] : []) });
|
||||
}
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
// « Donner la parole » : askToUnmute autorise le micro quand la modération
|
||||
// est active (documentation de l'IFrame API), puis la main est baissée.
|
||||
$('#v-mains').addEventListener('click', function (ev) {
|
||||
var b = ev.target.closest('[data-parole]');
|
||||
if (!b || !api) return;
|
||||
api.executeCommand('askToUnmute', b.dataset.participant);
|
||||
poster(URL_PAROLE.replace('__ELEVE__', b.dataset.parole)).then(rafraichir);
|
||||
});
|
||||
|
||||
rafraichir();
|
||||
setInterval(rafraichir, 2000);
|
||||
})();
|
||||
</script>
|
||||
@endsection
|
||||
|
|
@ -9,6 +9,7 @@ use App\Http\Controllers\InscriptionController;
|
|||
use App\Http\Controllers\RapportCspController;
|
||||
use App\Http\Controllers\RejoindreClasseController;
|
||||
use App\Http\Controllers\SeanceController;
|
||||
use App\Http\Controllers\VisioController;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
|
|
@ -87,6 +88,10 @@ Route::middleware(['auth:eleve', 'session.unique:eleve'])->group(function () {
|
|||
Route::post('/seances/{seance}/rejoindre', [SeanceController::class, 'rejoindre'])->name('seances.rejoindre');
|
||||
Route::get('/seances/{seance}/attente-eleve', [SeanceController::class, 'attenteEleve'])->name('seances.eleve.attente');
|
||||
Route::get('/seances/{seance}/etat-eleve', [SeanceController::class, 'etatEleve'])->name('seances.eleve.etat');
|
||||
Route::get('/seances/{seance}/visio/eleve', [VisioController::class, 'eleve'])->name('seances.visio.eleve');
|
||||
Route::get('/seances/{seance}/visio/eleve/etat', [VisioController::class, 'etatEleve'])->name('seances.visio.eleve.etat');
|
||||
Route::post('/seances/{seance}/visio/eleve/main', [VisioController::class, 'main'])->name('seances.visio.eleve.main');
|
||||
Route::post('/seances/{seance}/visio/eleve/participant', [VisioController::class, 'participant'])->name('seances.visio.eleve.participant');
|
||||
Route::post('/lab-sessions/{labSession}/corriger', [CorrectionController::class, 'corriger'])
|
||||
->middleware('throttle:6,1')
|
||||
->name('lab-sessions.corriger');
|
||||
|
|
@ -108,6 +113,12 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () {
|
|||
Route::post('/mes-classes/{classe}/desarchiver', [ClasseController::class, 'desarchiver'])->name('mes-classes.desarchiver');
|
||||
Route::post('/mes-classes/{classe}/seances', [SeanceController::class, 'store'])->name('mes-classes.seances.store');
|
||||
Route::get('/seances/{seance}/attente', [SeanceController::class, 'attente'])->name('seances.attente');
|
||||
Route::get('/seances/{seance}/visio', [VisioController::class, 'prof'])->name('seances.visio.prof');
|
||||
Route::post('/seances/{seance}/visio/ouvrir', [VisioController::class, 'ouvrir'])->name('seances.visio.ouvrir');
|
||||
Route::post('/seances/{seance}/visio/fermer', [VisioController::class, 'fermer'])->name('seances.visio.fermer');
|
||||
Route::get('/seances/{seance}/visio/etat', [VisioController::class, 'etatProf'])->name('seances.visio.etat');
|
||||
Route::post('/seances/{seance}/visio/eleves/{eleve}/parole', [VisioController::class, 'parole'])->name('seances.visio.parole');
|
||||
Route::post('/seances/{seance}/visio/chat', [VisioController::class, 'chat'])->name('seances.visio.chat');
|
||||
Route::post('/seances/{seance}/ouvrir', [SeanceController::class, 'ouvrir'])->name('seances.ouvrir');
|
||||
Route::post('/seances/{seance}/admettre/{labSession}', [SeanceController::class, 'admettre'])->name('seances.admettre');
|
||||
Route::get('/seances/{seance}/labsessions/{labSession}/console', [SeanceController::class, 'consoleProf'])->name('seances.console.prof.page');
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ test('les pages légales sont en ligne et la page de statut redirige vers le sit
|
|||
$this->get(route('confidentialite'))->assertOk()
|
||||
->assertSee('90 jours après l’archivage', false)
|
||||
->assertSee('aucune adresse e-mail', false)
|
||||
->assertSee('cdn.tailwindcss.com');
|
||||
->assertSee('cdn.tailwindcss.com')
|
||||
->assertSee('sans être enregistrés', false);
|
||||
$this->get(route('statut'))->assertRedirect('https://status.syllabuslab.fr');
|
||||
});
|
||||
|
||||
|
|
|
|||
139
portail/tests/Feature/VisioTest.php
Normal file
139
portail/tests/Feature/VisioTest.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Classe;
|
||||
use App\Models\Eleve;
|
||||
use App\Models\Lab;
|
||||
use App\Models\Seance;
|
||||
use App\Models\User;
|
||||
use App\Services\Visio\JetonVisio;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['visio.domaine' => 'meet.test', 'visio.jwt.secret' => str_repeat('s', 64)]);
|
||||
});
|
||||
|
||||
function seanceAvecClasse(): Seance
|
||||
{
|
||||
$classe = Classe::factory()->create();
|
||||
|
||||
return Seance::create(['classe_id' => $classe->id, 'lab_id' => Lab::factory()->create()->id, 'statut' => Seance::SALLE_ATTENTE, 'demarree_a' => now()]);
|
||||
}
|
||||
|
||||
function prof($test, User $prof)
|
||||
{
|
||||
return $test->withSession(['jeton_session_web' => $prof->demarreNouvelleSession()])->actingAs($prof, 'web');
|
||||
}
|
||||
|
||||
function eleve($test, Eleve $eleve)
|
||||
{
|
||||
return $test->withSession(['jeton_session_eleve' => $eleve->demarreNouvelleSession()])->actingAs($eleve, 'eleve');
|
||||
}
|
||||
|
||||
/** Décode un JWT HS256 en vérifiant sa signature, comme le fait Prosody. */
|
||||
function decoderJeton(string $jeton): array
|
||||
{
|
||||
[$entete, $corps, $signature] = explode('.', $jeton);
|
||||
$attendue = rtrim(strtr(base64_encode(hash_hmac('sha256', "{$entete}.{$corps}", str_repeat('s', 64), true)), '+/', '-_'), '=');
|
||||
expect(hash_equals($attendue, $signature))->toBeTrue();
|
||||
|
||||
return json_decode(base64_decode(strtr($corps, '-_', '+/')), true);
|
||||
}
|
||||
|
||||
test('le jeton est signé, limité à la salle, et seul le prof est modérateur', function () {
|
||||
$seance = seanceAvecClasse();
|
||||
$seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-1-abc']);
|
||||
|
||||
$prof = decoderJeton(app(JetonVisio::class)->pour($seance, 'prof-1', 'M. Prof', true));
|
||||
$eleve = decoderJeton(app(JetonVisio::class)->pour($seance, 'eleve-1', 'Léa M.', false));
|
||||
|
||||
expect($prof['room'])->toBe('syllabus-1-abc')
|
||||
->and($prof['iss'])->toBe('syllabus')->and($prof['aud'])->toBe('syllabus-visio')
|
||||
->and($prof['context']['user']['moderator'])->toBeTrue()
|
||||
->and($eleve['context']['user']['moderator'])->toBeFalse()
|
||||
->and($eleve['context']['user']['name'])->toBe('Léa M.')
|
||||
->and($eleve['exp'])->toBeGreaterThan(time());
|
||||
});
|
||||
|
||||
test('le prof ouvre la visio ; un autre prof ne peut ni l’ouvrir ni la voir', function () {
|
||||
$seance = seanceAvecClasse();
|
||||
|
||||
prof($this, User::factory()->create())->post(route('seances.visio.ouvrir', $seance))->assertForbidden();
|
||||
prof($this, $seance->classe->prof)->post(route('seances.visio.ouvrir', $seance))->assertRedirect(route('seances.visio.prof', $seance));
|
||||
|
||||
$seance->refresh();
|
||||
expect($seance->visioOuverte())->toBeTrue()->and($seance->visio_salle)->toMatch('/^syllabus-\d+-[a-z0-9]{16}$/');
|
||||
|
||||
$page = prof($this, $seance->classe->prof)->get(route('seances.visio.prof', $seance))->assertOk()->assertSee('https://meet.test/external_api.js', false);
|
||||
preg_match('/jwt: "([^"]+)"/', $page->getContent(), $m);
|
||||
expect(decoderJeton($m[1])['context']['user']['moderator'])->toBeTrue();
|
||||
expect($page->headers->get('Content-Security-Policy'))->toContain('frame-src \'self\' https://meet.test');
|
||||
});
|
||||
|
||||
test('un élève de la classe attend, puis entre sans être modérateur, avec un nom non modifiable ; un élève d’une autre classe est refusé', function () {
|
||||
$seance = seanceAvecClasse();
|
||||
$lea = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Léa', 'nom' => 'Martin']);
|
||||
|
||||
eleve($this, $lea)->get(route('seances.visio.eleve', $seance))->assertOk()->assertSee('pas encore ouvert la visio', false)->assertDontSee('jwt:', false);
|
||||
|
||||
$seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-x']);
|
||||
$page = eleve($this, $lea)->get(route('seances.visio.eleve', $seance))->assertOk()->assertSee('readOnlyName: true', false);
|
||||
preg_match('/jwt: "([^"]+)"/', $page->getContent(), $m);
|
||||
$jeton = decoderJeton($m[1]);
|
||||
expect($jeton['context']['user']['moderator'])->toBeFalse()->and($jeton['context']['user']['name'])->toBe('Léa M.');
|
||||
|
||||
$intrus = Eleve::factory()->create();
|
||||
eleve($this, $intrus)->get(route('seances.visio.eleve', $seance))->assertForbidden();
|
||||
});
|
||||
|
||||
test('les mains levées arrivent chez le prof dans l’ordre ; donner la parole baisse la main', function () {
|
||||
$seance = seanceAvecClasse();
|
||||
$seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-x']);
|
||||
$a = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Yanis', 'nom' => 'B']);
|
||||
$b = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Adam', 'nom' => 'D']);
|
||||
|
||||
eleve($this, $a)->postJson(route('seances.visio.eleve.participant', $seance), ['participant_id' => 'abc123']);
|
||||
eleve($this, $a)->postJson(route('seances.visio.eleve.main', $seance), ['levee' => true]);
|
||||
$this->travel(2)->seconds();
|
||||
eleve($this, $b)->postJson(route('seances.visio.eleve.main', $seance), ['levee' => true]);
|
||||
|
||||
$etat = prof($this, $seance->classe->prof)->getJson(route('seances.visio.etat', $seance))->json();
|
||||
expect(collect($etat['mains'])->pluck('nom')->all())->toBe(['Yanis B.', 'Adam D.'])
|
||||
->and($etat['mains'][0]['participant_id'])->toBe('abc123');
|
||||
|
||||
prof($this, $seance->classe->prof)->postJson(route('seances.visio.parole', [$seance, $a]))->assertOk();
|
||||
expect(collect(prof($this, $seance->classe->prof)->getJson(route('seances.visio.etat', $seance))->json('mains'))->pluck('nom')->all())->toBe(['Adam D.'])
|
||||
->and(eleve($this, $a)->getJson(route('seances.visio.eleve.etat', $seance))->json('main_levee'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('la salle d’attente montre les élèves présents tant que la visio est fermée', function () {
|
||||
$seance = seanceAvecClasse();
|
||||
$lea = Eleve::factory()->create(['classe_id' => $seance->classe_id, 'prenom' => 'Léa', 'nom' => 'M']);
|
||||
|
||||
eleve($this, $lea)->getJson(route('seances.visio.eleve.etat', $seance))->assertJson(['ouverte' => false]);
|
||||
|
||||
expect(prof($this, $seance->classe->prof)->getJson(route('seances.visio.etat', $seance))->json('attente'))->toBe([['nom' => 'Léa M.']]);
|
||||
});
|
||||
|
||||
test('les scripts des pages de visio sont du JavaScript valide', function () {
|
||||
if (Process::run('node --version')->failed()) {
|
||||
$this->markTestSkipped('Node absent');
|
||||
}
|
||||
$seance = seanceAvecClasse();
|
||||
$seance->update(['visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-x']);
|
||||
$lea = Eleve::factory()->create(['classe_id' => $seance->classe_id]);
|
||||
|
||||
$pages = [
|
||||
prof($this, $seance->classe->prof)->get(route('seances.visio.prof', $seance))->getContent(),
|
||||
eleve($this, $lea)->get(route('seances.visio.eleve', $seance))->getContent(),
|
||||
];
|
||||
foreach ($pages as $html) {
|
||||
preg_match_all('#<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>#s', $html, $scripts);
|
||||
foreach ($scripts[1] as $code) {
|
||||
$fichier = tempnam(sys_get_temp_dir(), 'js').'.js';
|
||||
file_put_contents($fichier, $code);
|
||||
$r = Process::run(['node', '--check', $fichier]);
|
||||
unlink($fichier);
|
||||
expect($r->successful())->toBeTrue($r->errorOutput());
|
||||
}
|
||||
}
|
||||
});
|
||||
31
portail/tests/Reels/preparer-visio.php
Normal file
31
portail/tests/Reels/preparer-visio.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
// Données de test de la visio (étape 15), sur le serveur :
|
||||
// sudo -u www-data HOME=/tmp php8.3 artisan tinker tests/Reels/preparer-visio.php
|
||||
// Identifiants dans un fichier 600, jamais affichés. À supprimer après le test.
|
||||
|
||||
use App\Models\Classe;
|
||||
use App\Models\Eleve;
|
||||
use App\Models\Etablissement;
|
||||
use App\Models\Lab;
|
||||
use App\Models\Seance;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
$etablissement = Etablissement::create(['nom' => 'Lycée Test Visio', 'ville' => 'Test']);
|
||||
$motDePasse = Str::password(24, symbols: false);
|
||||
$prof = User::create(['name' => 'Prof Visio', 'email' => 'prof-visio@syllabus.test', 'password' => $motDePasse, 'etablissement_id' => $etablissement->id]);
|
||||
$classe = Classe::create(['etablissement_id' => $etablissement->id, 'user_id' => $prof->id, 'nom' => 'Classe Test Visio', 'formation' => 'Test']);
|
||||
$eleves = [];
|
||||
foreach ([['Lea', 'Martin', 'lea.visio'], ['Yanis', 'Bernard', 'yanis.visio']] as [$prenom, $nom, $identifiant]) {
|
||||
$code = Str::upper(Str::random(6));
|
||||
Eleve::create(['classe_id' => $classe->id, 'prenom' => $prenom, 'nom' => $nom, 'identifiant' => $identifiant, 'code_secret' => Hash::make($code), 'jeton_qr' => Str::random(32)]);
|
||||
$eleves[] = ['identifiant' => $identifiant, 'code' => $code];
|
||||
}
|
||||
$seance = Seance::create(['classe_id' => $classe->id, 'lab_id' => Lab::where('identifiant', '08-deux-lan-routeur')->value('id'), 'statut' => Seance::SALLE_ATTENTE, 'demarree_a' => now()]);
|
||||
|
||||
$fichier = '/tmp/visio-identifiants.json';
|
||||
file_put_contents($fichier, json_encode(['prof' => ['email' => $prof->email, 'mot_de_passe' => $motDePasse], 'eleves' => $eleves, 'seance_id' => $seance->id]));
|
||||
chmod($fichier, 0600);
|
||||
echo "données de test prêtes (séance {$seance->id})\n";
|
||||
91
portail/tests/Reels/visio-e2e.mjs
Normal file
91
portail/tests/Reels/visio-e2e.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Critère de l'étape 15, dans de vrais navigateurs (Chromium, caméra et micro
|
||||
// simulés) : un prof et deux élèves rejoignent la visio ; un élève lève la
|
||||
// main ; le prof lui donne la parole ; un intrus sans jeton ne peut pas entrer.
|
||||
// node visio-e2e.mjs <fichier d'identifiants> [https://syllabuslab.fr]
|
||||
import { chromium } from 'playwright';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const ids = JSON.parse(readFileSync(process.argv[2], 'utf8'));
|
||||
const BASE = process.argv[3] ?? 'https://syllabuslab.fr';
|
||||
const S = ids.seance_id;
|
||||
const navigateur = await chromium.launch({ args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'] });
|
||||
const contexte = () => navigateur.newContext({ locale: 'fr-FR', permissions: ['camera', 'microphone'] });
|
||||
const etape = (texte) => console.log(`${new Date().toISOString().slice(11, 19)} ${texte}`);
|
||||
const attendre = async (fn, delai = 30000, pas = 500) => {
|
||||
const fin = Date.now() + delai;
|
||||
while (Date.now() < fin) { const r = await fn(); if (r) return r; await new Promise((ok) => setTimeout(ok, pas)); }
|
||||
throw new Error('délai dépassé');
|
||||
};
|
||||
|
||||
async function connexion(page, champs, bouton) {
|
||||
await page.goto(`${BASE}/connexion`);
|
||||
for (const [nom, valeur] of Object.entries(champs)) await page.fill(`input[name="${nom}"]`, valeur);
|
||||
await page.click(bouton);
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Le prof ouvre la visio.
|
||||
const prof = await (await contexte()).newPage();
|
||||
await connexion(prof, { email: ids.prof.email, password: ids.prof.mot_de_passe }, 'form[action$="/connexion/prof"] button[type=submit]');
|
||||
await prof.goto(`${BASE}/seances/${S}/visio`);
|
||||
if (await prof.locator('text=Ouvrir la visio').count()) await prof.click('text=Ouvrir la visio');
|
||||
await prof.waitForSelector('#v-scene iframe', { timeout: 30000 });
|
||||
etape('prof : visio ouverte, Jitsi chargé');
|
||||
|
||||
// 2. Deux élèves la rejoignent.
|
||||
const eleves = [];
|
||||
for (const e of ids.eleves) {
|
||||
const page = await (await contexte()).newPage();
|
||||
await connexion(page, { identifiant: e.identifiant, code: e.code }, 'form[action$="/connexion/eleve"] button[type=submit]');
|
||||
await page.goto(`${BASE}/seances/${S}/visio/eleve`);
|
||||
await page.waitForSelector('#v-scene iframe', { timeout: 30000 });
|
||||
eleves.push(page);
|
||||
}
|
||||
const enLigne = await attendre(async () => {
|
||||
const t = await prof.textContent('#v-nb');
|
||||
return /3 en ligne/.test(t ?? '') && t;
|
||||
}, 60000);
|
||||
etape(`prof : ${enLigne.trim()} (le prof et les deux élèves)`);
|
||||
|
||||
// 3. Léa lève la main ; elle apparaît chez le prof.
|
||||
const [lea, yanis] = eleves;
|
||||
await lea.click('[data-main-e]');
|
||||
await attendre(async () => (await prof.locator('#v-mains [data-parole]').count()) === 1, 30000);
|
||||
etape(`prof : main levée reçue de « ${(await prof.locator('#v-mains strong').first().textContent())} »`);
|
||||
|
||||
// 4. Yanis, sans la parole, ne peut pas réactiver son micro.
|
||||
await yanis.click('[data-micro-e]');
|
||||
await new Promise((ok) => setTimeout(ok, 4000));
|
||||
const statutYanis = (await yanis.textContent('[data-statut-e]')).trim();
|
||||
etape(`Yanis tente d'ouvrir son micro sans la parole → « ${statutYanis} »`);
|
||||
|
||||
// 5. Le prof donne la parole à Léa : elle peut ouvrir son micro.
|
||||
await prof.click('#v-mains [data-parole]');
|
||||
await attendre(async () => (await lea.getAttribute('[data-main-e]', 'aria-pressed')) === 'false', 30000);
|
||||
etape('Léa : sa main est baissée (parole donnée)');
|
||||
await lea.click('[data-micro-e]');
|
||||
const statutLea = await attendre(async () => {
|
||||
const t = (await lea.textContent('[data-statut-e]')).trim();
|
||||
return t === 'Tu as la parole' && t;
|
||||
}, 30000);
|
||||
etape(`Léa ouvre son micro → « ${statutLea} »`);
|
||||
|
||||
// 6. Un intrus, sans jeton, tente d'entrer directement dans la salle.
|
||||
const salle = await prof.evaluate(() => document.querySelector('#v-scene iframe').src.split('?')[0].split('#')[0].split('/').pop());
|
||||
const intrus = await (await contexte()).newPage();
|
||||
await intrus.goto(`${BASE.replace('://', '://meet.')}/${salle}`);
|
||||
await new Promise((ok) => setTimeout(ok, 12000));
|
||||
const toujours = (await prof.textContent('#v-nb')).trim();
|
||||
const ecranIntrus = (await intrus.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 160);
|
||||
etape(`intrus sans jeton : le prof voit toujours « ${toujours} » ; écran de l'intrus : « ${ecranIntrus} »`);
|
||||
|
||||
const ok = /3 en ligne/.test(toujours) && statutYanis === 'En sourdine' && statutLea === 'Tu as la parole';
|
||||
console.log(ok ? 'CRITÈRE DE L’ÉTAPE 15 : RÉUSSI' : 'CRITÈRE DE L’ÉTAPE 15 : ÉCHOUÉ');
|
||||
process.exitCode = ok ? 0 : 1;
|
||||
} catch (e) {
|
||||
console.error('ÉCHEC :', e.message);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await navigateur.close();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue