passe le portail en HTTPS sur syllabuslab.fr, avec en-têtes de sécurité et CSP
Certificat Let's Encrypt obtenu et renouvelé par certbot (authentificateur nginx), expiration surveillée par Prometheus. TLS selon Mozilla v6.0 intermediate, HSTS, CSP stricte avec nonce sur l'application et adaptée sur la vitrine, xterm.js servi par le portail. Page de statut prévue sur status.syllabuslab.fr, administration de Grafana et Kuma par tunnel seul. Corrige aussi une faille XSS dans la page de classe, la page /cours en erreur depuis l'étape 8, et NodeSource relancé à chaque déploiement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
aa004c0736
commit
618cd7efbe
30 changed files with 987 additions and 24 deletions
31
portail/app/Http/Controllers/RapportCspController.php
Normal file
31
portail/app/Http/Controllers/RapportCspController.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Reçoit les violations de CSP envoyées par les navigateurs : c'est le seul
|
||||
* moyen de savoir qu'une page casse chez un élève. Seuls quelques champs
|
||||
* sont gardés, tronqués, sans la chaîne de requête des URL.
|
||||
*/
|
||||
class RapportCspController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$rapport = json_decode($request->getContent(), true)['csp-report'] ?? null;
|
||||
|
||||
if (is_array($rapport)) {
|
||||
Log::warning('Violation CSP', [
|
||||
'page' => Str::limit(strtok((string) ($rapport['document-uri'] ?? ''), '?'), 200),
|
||||
'directive' => Str::limit((string) ($rapport['violated-directive'] ?? $rapport['effective-directive'] ?? ''), 100),
|
||||
'bloque' => Str::limit(strtok((string) ($rapport['blocked-uri'] ?? ''), '?'), 200),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
87
portail/app/Http/Middleware/PolitiqueSecuriteContenu.php
Normal file
87
portail/app/Http/Middleware/PolitiqueSecuriteContenu.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Content-Security-Policy, en deux niveaux (étape 12) :
|
||||
*
|
||||
* - application (connexion, inscription, toute page authentifiée) : seuls
|
||||
* nos scripts et ceux qui portent le nonce de la requête s'exécutent. Une
|
||||
* injection de script dans un nom d'élève, par exemple, serait bloquée
|
||||
* par le navigateur ;
|
||||
* - vitrine (pages reprises du design) : scripts en ligne et CDN autorisés,
|
||||
* faute de pouvoir les réécrire ici. Ces pages n'affichent aucune donnée
|
||||
* d'utilisateur.
|
||||
*
|
||||
* Les violations sont envoyées à /csp-rapport (journal Laravel).
|
||||
* Les autres en-têtes (HSTS, nosniff…) sont posés par Nginx.
|
||||
*/
|
||||
class PolitiqueSecuriteContenu
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
Vite::useCspNonce();
|
||||
|
||||
$reponse = $next($request);
|
||||
|
||||
if (! $reponse->headers->has('Content-Security-Policy')) {
|
||||
$reponse->headers->set('Content-Security-Policy', $this->politique($request));
|
||||
}
|
||||
|
||||
return $reponse;
|
||||
}
|
||||
|
||||
private function politique(Request $request): string
|
||||
{
|
||||
$communes = [
|
||||
'default-src' => "'self'",
|
||||
'img-src' => "'self' data:",
|
||||
'font-src' => "'self' https://fonts.gstatic.com",
|
||||
'object-src' => "'none'",
|
||||
'base-uri' => "'self'",
|
||||
'form-action' => "'self'",
|
||||
'frame-ancestors' => "'none'",
|
||||
'report-uri' => '/csp-rapport',
|
||||
];
|
||||
|
||||
$directives = $this->estApplication($request)
|
||||
? [
|
||||
'script-src' => "'self' 'nonce-".Vite::cspNonce()."'",
|
||||
// Attributs style= présents dans le gabarit commun : sans
|
||||
// risque d'exécution de code, tolérés.
|
||||
'style-src' => "'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
'connect-src' => "'self' wss://{$request->getHost()}",
|
||||
]
|
||||
: [
|
||||
'script-src' => "'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdn.tailwindcss.com",
|
||||
'style-src' => "'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net",
|
||||
'connect-src' => "'self'",
|
||||
'frame-src' => 'https://www.youtube-nocookie.com',
|
||||
];
|
||||
|
||||
return collect($communes + $directives)
|
||||
->map(fn (string $valeur, string $directive) => "{$directive} {$valeur}")
|
||||
->implode('; ');
|
||||
}
|
||||
|
||||
private function estApplication(Request $request): bool
|
||||
{
|
||||
$route = $request->route();
|
||||
|
||||
if ($route === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array($route->getName(), ['login', 'register'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return collect($route->gatherMiddleware())
|
||||
->contains(fn ($middleware) => is_string($middleware) && str_starts_with($middleware, 'auth:'));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\PolitiqueSecuriteContenu;
|
||||
use App\Http\Middleware\VerifieSessionUnique;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
|
|
@ -16,6 +17,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
$middleware->alias([
|
||||
'session.unique' => VerifieSessionUnique::class,
|
||||
]);
|
||||
$middleware->web(append: [PolitiqueSecuriteContenu::class]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
|
|
|
|||
264
portail/config/cours.php
Normal file
264
portail/config/cours.php
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
<?php
|
||||
|
||||
// Catalogue des cours gratuits, inspiré des référentiels de l'Éducation nationale.
|
||||
// types : CM (cours magistral), TD (travaux dirigés), TP (travaux pratiques sur VM), QCM.
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'nsi',
|
||||
'nom' => 'Lycée NSI',
|
||||
'sous_titre' => 'Première et Terminale · spécialité NSI',
|
||||
'couleur' => 'bg-[#FFD23F]',
|
||||
'blocs' => [
|
||||
[
|
||||
'titre' => 'Première · Représentation des données',
|
||||
'cours' => [
|
||||
['titre' => 'Entiers naturels : binaire et hexadécimal', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Entiers relatifs et complément à deux', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Nombres flottants et leurs pièges', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Codage des caractères : ASCII, Unicode, UTF-8', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 2],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Première · Architectures et systèmes',
|
||||
'cours' => [
|
||||
['titre' => 'Modèle de von Neumann', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Système d’exploitation et commandes Linux', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Droits et permissions des fichiers', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Réseau : TCP/IP et protocole du bit alterné', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Première · Web et données',
|
||||
'cours' => [
|
||||
['titre' => 'HTTP, formulaires GET et POST', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Traitement de données en tables CSV', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Terminale · Architectures, réseaux, sécurité',
|
||||
'cours' => [
|
||||
['titre' => 'Processus et ordonnancement', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Routage : protocoles RIP et OSPF', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Chiffrement symétrique, asymétrique et HTTPS', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Terminale · Données et structures',
|
||||
'cours' => [
|
||||
['titre' => 'Bases de données relationnelles et SQL', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Piles, files et listes chaînées', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Arbres binaires et graphes', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 4],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'sio',
|
||||
'nom' => 'BTS SIO SISR',
|
||||
'sous_titre' => 'Services informatiques aux organisations · option SISR',
|
||||
'couleur' => 'bg-[#7CF2C0]',
|
||||
'blocs' => [
|
||||
[
|
||||
'titre' => 'Bloc 1 · Support et mise à disposition de services',
|
||||
'cours' => [
|
||||
['titre' => 'Gérer le parc avec GLPI', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Traiter un incident : ticket, diagnostic, résolution', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Installer et configurer un poste Debian', 'types' => ['TP', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Présence en ligne : nom de domaine et DNS public', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Bloc 2 · Administration des systèmes et des réseaux',
|
||||
'cours' => [
|
||||
['titre' => 'Adressage IPv4 et sous-réseaux', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'VLAN et commutation', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Routage inter-VLAN', 'types' => ['TD', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Serveur DHCP sous Debian', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Serveur DNS avec BIND 9', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Annuaire Samba AD et stratégies de groupe', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Supervision avec Zabbix', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Sauvegarde et restauration', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Virtualisation et conteneurs', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Bloc 3 · Cybersécurité des services',
|
||||
'cours' => [
|
||||
['titre' => 'RGPD et protection des données', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Authentification et mots de passe', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Pare-feu avec nftables', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'VPN avec WireGuard', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Journalisation et détection d’intrusion', 'types' => ['TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'ciel',
|
||||
'nom' => 'BTS CIEL',
|
||||
'sous_titre' => 'Cybersécurité, informatique et réseaux, électronique · option IR',
|
||||
'couleur' => 'bg-[#C4B5FD]',
|
||||
'blocs' => [
|
||||
[
|
||||
'titre' => 'Réseaux',
|
||||
'cours' => [
|
||||
['titre' => 'Numération et codage de l’information', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Modèle OSI et encapsulation', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Ethernet, câblage et commutation', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Adressage IPv4 et IPv6', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Services réseau : SSH, HTTP, FTP', 'types' => ['TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Capturer et analyser des trames avec tcpdump', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Informatique',
|
||||
'cours' => [
|
||||
['titre' => 'Scripts Bash', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Python pour l’administration système', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Objets connectés et protocole MQTT', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Cybersécurité',
|
||||
'cours' => [
|
||||
['titre' => 'Analyse de risques', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Durcir un serveur Linux', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Détecter une attaque dans les journaux', 'types' => ['TP', 'QCM'], 'heures' => 2],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'but',
|
||||
'nom' => 'BUT R&T',
|
||||
'sous_titre' => 'Réseaux et télécommunications · parcours Cybersécurité et DevCloud',
|
||||
'couleur' => 'bg-[#FF6FB5]',
|
||||
'blocs' => [
|
||||
[
|
||||
'titre' => 'Semestres 1 et 2',
|
||||
'cours' => [
|
||||
['titre' => 'Initiation aux réseaux informatiques', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Fondamentaux des systèmes Linux', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Architecture des systèmes numériques', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Transmission et supports physiques', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Bases de la programmation', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Semestres 3 et 4',
|
||||
'cours' => [
|
||||
['titre' => 'Routage dynamique OSPF', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Services réseaux : DNS, mail, web', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Réseaux d’opérateurs : BGP et MPLS', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Supervision et métrologie', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Automatiser avec Ansible', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Parcours Cybersécurité et DevCloud',
|
||||
'cours' => [
|
||||
['titre' => 'Cryptographie appliquée : PKI et certificats', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Conteneurs Docker et orchestration', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Auditer la sécurité d’un réseau', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'tssr',
|
||||
'nom' => 'Titre pro TSSR',
|
||||
'sous_titre' => 'Technicien supérieur systèmes et réseaux · niveau 5',
|
||||
'couleur' => 'bg-white',
|
||||
'blocs' => [
|
||||
[
|
||||
'titre' => 'CCP 1 · Assister les utilisateurs',
|
||||
'cours' => [
|
||||
['titre' => 'Mettre en service un poste client', 'types' => ['TP', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Diagnostiquer une panne réseau', 'types' => ['TD', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Rédiger une procédure utilisateur', 'types' => ['CM', 'TD'], 'heures' => 2],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'CCP 2 · Infrastructure centralisée',
|
||||
'cours' => [
|
||||
['titre' => 'Déployer un domaine Samba AD', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
['titre' => 'Partages de fichiers et ACL', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Sauvegardes avec restic', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 2],
|
||||
['titre' => 'Superviser une infrastructure', 'types' => ['TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'CCP 3 · Infrastructure distribuée',
|
||||
'cours' => [
|
||||
['titre' => 'Déployer des services dans le cloud', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Docker Compose en production', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Automatiser l’administration en Bash', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'VPN site à site', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'prog',
|
||||
'nom' => 'Programmation',
|
||||
'sous_titre' => 'Python, web, SQL, Bash, C · exercices dans l’IDE en ligne',
|
||||
'couleur' => 'bg-[#FF6FB5]',
|
||||
'blocs' => [
|
||||
[
|
||||
'titre' => 'Python · les bases',
|
||||
'cours' => [
|
||||
['titre' => 'Variables et types', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-pair'],
|
||||
['titre' => 'Conditions : if, elif, else', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-pair'],
|
||||
['titre' => 'Boucles for et while', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'py-somme'],
|
||||
['titre' => 'Fonctions et paramètres', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'py-pair'],
|
||||
['titre' => 'Listes et tuples', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'py-somme'],
|
||||
['titre' => 'Chaînes de caractères', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-voyelles'],
|
||||
['titre' => 'Dictionnaires', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-voyelles'],
|
||||
['titre' => 'Lire et écrire des fichiers CSV', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-somme'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Python · aller plus loin',
|
||||
'cours' => [
|
||||
['titre' => 'Récursivité', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'py-somme'],
|
||||
['titre' => 'Programmation orientée objet', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 4, 'exercice' => 'py-pair'],
|
||||
['titre' => 'Algorithmes de tri', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'py-somme'],
|
||||
['titre' => 'Recherche dichotomique', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-somme'],
|
||||
['titre' => 'Tester son code avec assert', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-pair'],
|
||||
['titre' => 'Sockets : un client réseau en Python', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Web',
|
||||
'cours' => [
|
||||
['titre' => 'HTML : structurer une page', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'web-bouton'],
|
||||
['titre' => 'CSS : flexbox et grid', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'web-bouton'],
|
||||
['titre' => 'JavaScript et le DOM', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'web-bouton'],
|
||||
['titre' => 'Formulaires et validation', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'web-bouton'],
|
||||
['titre' => 'API REST et JSON avec fetch', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'web-bouton'],
|
||||
['titre' => 'PHP côté serveur', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Bases de données',
|
||||
'cours' => [
|
||||
['titre' => 'SELECT, WHERE, ORDER BY', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'sql-select'],
|
||||
['titre' => 'Agrégats et GROUP BY', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'sql-group'],
|
||||
['titre' => 'Jointures', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'sql-select'],
|
||||
['titre' => 'Créer des tables et des contraintes', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'sql-select'],
|
||||
['titre' => 'Injection SQL : comprendre et s’en protéger', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'sql-select'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'titre' => 'Système et outils',
|
||||
'cours' => [
|
||||
['titre' => 'Scripts Bash avancés', 'types' => ['CM', 'IDE', 'TP', 'QCM'], 'heures' => 3, 'exercice' => 'bash-tp'],
|
||||
['titre' => 'Expressions régulières', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'bash-tp'],
|
||||
['titre' => 'Git : versionner son code', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3],
|
||||
['titre' => 'Langage C : pointeurs et mémoire', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
19
portail/package-lock.json
generated
19
portail/package-lock.json
generated
|
|
@ -4,6 +4,10 @@
|
|||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
|
|
@ -660,6 +664,21 @@
|
|||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
||||
|
|
|
|||
|
|
@ -14,5 +14,9 @@
|
|||
},
|
||||
"optionalDependencies": {
|
||||
"@laravel/multiplex": "^0.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
portail/resources/js/console.js
Normal file
9
portail/resources/js/console.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// xterm.js servi par le portail lui-même (étape 12) plutôt que par un CDN :
|
||||
// la CSP stricte des pages de l'application n'autorise que nos propres
|
||||
// scripts, et des réseaux scolaires filtrent les CDN.
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
window.Terminal = Terminal;
|
||||
window.FitAddon = { FitAddon };
|
||||
|
|
@ -55,7 +55,7 @@
|
|||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
<script nonce="{{ Vite::cspNonce() }}">
|
||||
document.querySelectorAll('[data-etab]').forEach(function (s) {
|
||||
var box = s.parentNode.querySelector('[data-etab-autre]');
|
||||
function maj() {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@
|
|||
|
||||
@section('contenu')
|
||||
<section style="padding:112px 20px 96px;background:#fff"><div style="max-width:900px;margin:0 auto"><p style="margin:0;font-size:15px;font-weight:700;color:#0b57d0;text-align:center">Gratuit, sans compte</p><h1 style="margin:20px 0 0;font-size:clamp(40px,6vw,72px);line-height:1.04;font-weight:800;letter-spacing:-0.03em;color:#0b1220;text-align:center;text-wrap:balance">99 cours gratuits, calés sur vos référentiels.</h1><p style="margin:24px auto 0;max-width:660px;font-size:20px;line-height:1.6;font-weight:500;color:#3c4043;text-align:center;text-wrap:pretty">Chaque cours suit le découpage de l’Éducation nationale : un CM pour comprendre, un TD pour s’exercer sur papier, un TP sur une vraie VM, un QCM corrigé et expliqué.</p><p style="margin:28px 0 0;text-align:center;font-size:15px;font-weight:700;color:#5f6368">99 cours · 45 TD · 59 TP sur VM · 98 QCM corrigés</p><div style="margin-top:44px;display:flex;flex-direction:column;align-items:center;gap:14px"><a href="#nsi" style="display:inline-flex;align-items:center;height:56px;padding:0 32px;border-radius:999px;background:#0b57d0;color:#fff;font-size:17px;font-weight:700;text-decoration:none">Choisir mon cursus</a></div><nav aria-label="Cursus" style="margin-top:28px;display:flex;flex-wrap:wrap;justify-content:center;gap:8px 24px"><a href="#nsi" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">Lycée NSI</a><a href="#sio" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">BTS SIO SISR</a><a href="#ciel" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">BTS CIEL</a><a href="#but" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">BUT R&T</a><a href="#tssr" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">Titre pro TSSR</a><a href="#prog" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">Programmation</a></nav></div></section>
|
||||
{{-- Boucle et données absentes de la maquette design/ (perdues lors de sa
|
||||
refonte) : rétablies d'après design/_backup-ludique, étape 12. --}}
|
||||
@php
|
||||
$cursus = config('cours');
|
||||
$fond = ['CM' => 'bg-white', 'TD' => 'bg-[#eef0fd]', 'TP' => 'bg-[#e6f4ea]', 'QCM' => 'bg-[#fef7e0]', 'IDE' => 'bg-[#13233A] text-white'];
|
||||
@endphp
|
||||
@foreach ($cursus as $c)
|
||||
<section id="{{ $c['id'] }}" class="scroll-mt-24" style="border-top:1px solid #e3e5e8" aria-labelledby="t-{{ $c['id'] }}">
|
||||
<div class="mx-auto max-w-7xl px-5 py-16 lg:px-10">
|
||||
<div class="flex flex-wrap items-end gap-x-8 gap-y-3">
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
<span class="font-mono text-sm font-semibold">({{ session('nombre_importes') }} élève(s) importé(s))</span>
|
||||
@endif
|
||||
</h2>
|
||||
<button type="button" onclick="window.print()" class="border border-[#dadce0] rounded-lg bg-black px-4 py-2 font-extrabold text-white">Imprimer</button>
|
||||
<button type="button" data-imprimer class="border border-[#dadce0] rounded-lg bg-black px-4 py-2 font-extrabold text-white">Imprimer</button>
|
||||
</div>
|
||||
<p class="mt-2 font-semibold print:hidden">Le code secret ne sera plus jamais affiché après cette page : imprime ou note-le maintenant.</p>
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2 print:grid-cols-1">
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
<td class="px-4 py-3 font-mono text-sm">{{ $eleve->identifiant }}</td>
|
||||
<td class="px-4 py-3 font-semibold">{{ $eleve->derniere_connexion_a?->diffForHumans() ?? 'jamais' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<form action="{{ route('mes-classes.eleves.regenererCode', [$classe, $eleve]) }}" method="post" onsubmit="return confirm('Régénérer le code de {{ $eleve->prenom }} ? L’ancien code ne fonctionnera plus.');">
|
||||
<form action="{{ route('mes-classes.eleves.regenererCode', [$classe, $eleve]) }}" method="post" data-confirmer="Régénérer le code de {{ $eleve->prenom }} ? L’ancien code ne fonctionnera plus.">
|
||||
@csrf
|
||||
<button type="submit" class="font-bold underline underline-offset-4">Régénérer le code</button>
|
||||
</form>
|
||||
|
|
@ -133,4 +133,10 @@
|
|||
@endif
|
||||
</section>
|
||||
</div>
|
||||
<script nonce="{{ Vite::cspNonce() }}">
|
||||
document.querySelectorAll('[data-imprimer]').forEach(function (b) { b.addEventListener('click', function () { window.print(); }); });
|
||||
document.querySelectorAll('form[data-confirmer]').forEach(function (f) {
|
||||
f.addEventListener('submit', function (e) { if (!window.confirm(f.dataset.confirmer)) e.preventDefault(); });
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
|
|
|
|||
|
|
@ -66,11 +66,11 @@
|
|||
@endif
|
||||
</section>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||
<script src="{{ asset('js/console-reelle.js') }}"></script>
|
||||
<script>
|
||||
@vite('resources/js/console.js')
|
||||
<script nonce="{{ Vite::cspNonce() }}" src="{{ asset('js/console-reelle.js') }}"></script>
|
||||
<script nonce="{{ Vite::cspNonce() }}">
|
||||
// Après les modules Vite (différés) : window.Terminal est défini.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.onglet-machine').forEach(function (bouton) {
|
||||
bouton.addEventListener('click', function () {
|
||||
var id = bouton.dataset.onglet;
|
||||
|
|
@ -91,6 +91,7 @@
|
|||
window.SyllabusConsoleReelle.mount(document.getElementById('terminal-{{ $machine->id }}'), { jeton: d.jeton, lectureSeule: false, reveil: true });
|
||||
});
|
||||
@endforeach
|
||||
});
|
||||
</script>
|
||||
@else
|
||||
<div class="py-20 text-center">
|
||||
|
|
@ -101,7 +102,7 @@
|
|||
</div>
|
||||
|
||||
@if (in_array($labSession->etat, [$etats::EN_ATTENTE, $etats::ADMIS, $etats::ECHEC], true))
|
||||
<script>
|
||||
<script nonce="{{ Vite::cspNonce() }}">
|
||||
(function () {
|
||||
var etatAffiche = @json($labSession->etat);
|
||||
setInterval(function () {
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@
|
|||
<div id="terminal-{{ $machine->id }}" class="h-full"></div>
|
||||
</div>
|
||||
@endforeach
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||
<script src="{{ asset('js/console-reelle.js') }}"></script>
|
||||
<script>
|
||||
@vite('resources/js/console.js')
|
||||
<script nonce="{{ Vite::cspNonce() }}" src="{{ asset('js/console-reelle.js') }}"></script>
|
||||
<script nonce="{{ Vite::cspNonce() }}">
|
||||
// Après les modules Vite (différés) : window.Terminal est défini.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.onglet-machine').forEach(function (bouton) {
|
||||
bouton.addEventListener('click', function () {
|
||||
var id = bouton.dataset.onglet;
|
||||
|
|
@ -40,6 +40,7 @@
|
|||
window.SyllabusConsoleReelle.mount(document.getElementById('terminal-{{ $machine->id }}'), { jeton: d.jeton, lectureSeule: true });
|
||||
});
|
||||
@endforeach
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ use App\Http\Controllers\ConsoleController;
|
|||
use App\Http\Controllers\CorrectionController;
|
||||
use App\Http\Controllers\EleveTableauDeBordController;
|
||||
use App\Http\Controllers\InscriptionController;
|
||||
use App\Http\Controllers\RapportCspController;
|
||||
use App\Http\Controllers\SeanceController;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::view('/', 'accueil')->name('accueil');
|
||||
|
|
@ -22,6 +24,11 @@ Route::view('/inscription', 'auth.inscription')->name('register');
|
|||
Route::view('/classes/suivi', 'classes.suivi')->name('suivi');
|
||||
Route::view('/examen', 'examen')->name('examen');
|
||||
// À brancher sur l'envoi d'e-mail (Mail::to('adam@belkacemi.me')->send(...)).
|
||||
// Envoyé par les navigateurs eux-mêmes (pas de jeton CSRF possible).
|
||||
Route::post('/csp-rapport', RapportCspController::class)
|
||||
->withoutMiddleware(ValidateCsrfToken::class)
|
||||
->middleware('throttle:30,1')
|
||||
->name('csp.rapport');
|
||||
Route::post('/contact', fn () => back()->with('contact_ok', true))->name('contact');
|
||||
Route::view('/fonctionnalites', 'fonctionnalites')->name('fonctionnalites');
|
||||
Route::view('/legal', 'legal')->name('legal');
|
||||
|
|
|
|||
22
portail/tests/Feature/PagesPubliquesTest.php
Normal file
22
portail/tests/Feature/PagesPubliquesTest.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Toutes les pages publiques sans paramètre doivent s'afficher : la page
|
||||
// /cours a été en erreur 500 de l'étape 8 à l'étape 12 sans que personne
|
||||
// ne le voie.
|
||||
test('chaque page publique s’affiche sans erreur', function () {
|
||||
$pages = collect(Route::getRoutes())
|
||||
->filter(fn ($r) => in_array('GET', $r->methods(), true)
|
||||
&& $r->getName() !== null
|
||||
&& ! str_contains($r->uri(), '{')
|
||||
&& ! collect($r->gatherMiddleware())->contains(fn ($m) => is_string($m) && str_starts_with($m, 'auth')))
|
||||
->reject(fn ($r) => str_starts_with($r->uri(), 'horizon') || str_starts_with($r->uri(), '_') || $r->uri() === 'up');
|
||||
|
||||
expect($pages)->not->toBeEmpty();
|
||||
|
||||
$echecs = $pages->mapWithKeys(fn ($r) => [$r->uri() => $this->get('/'.ltrim($r->uri(), '/'))->getStatusCode()])
|
||||
->reject(fn ($code) => $code === 200);
|
||||
|
||||
expect($echecs->all())->toBe([]);
|
||||
});
|
||||
48
portail/tests/Feature/PolitiqueSecuriteContenuTest.php
Normal file
48
portail/tests/Feature/PolitiqueSecuriteContenuTest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Eleve;
|
||||
|
||||
function politique($reponse): array
|
||||
{
|
||||
return collect(explode(';', $reponse->headers->get('Content-Security-Policy')))
|
||||
->mapWithKeys(function (string $d) {
|
||||
$morceaux = preg_split('/\s+/', trim($d), 2);
|
||||
|
||||
return [$morceaux[0] => $morceaux[1] ?? ''];
|
||||
})->all();
|
||||
}
|
||||
|
||||
test("les pages de l'application ont une CSP stricte : pas de script en ligne sans le nonce", function () {
|
||||
$eleve = Eleve::factory()->create();
|
||||
$jeton = $eleve->demarreNouvelleSession();
|
||||
|
||||
$reponse = $this->withSession(['jeton_session_eleve' => $jeton])->actingAs($eleve, 'eleve')->get(route('eleve'));
|
||||
$csp = politique($reponse);
|
||||
|
||||
expect($csp['script-src'])->toMatch("/^'self' 'nonce-[A-Za-z0-9+\\/=]+'$/")
|
||||
->and($csp['script-src'])->not->toContain('unsafe-inline')
|
||||
->and($csp['object-src'])->toBe("'none'")
|
||||
->and($csp['frame-ancestors'])->toBe("'none'");
|
||||
});
|
||||
|
||||
test('la page de connexion est stricte aussi, la vitrine tolère les scripts en ligne', function () {
|
||||
expect(politique($this->get(route('login')))['script-src'])->not->toContain('unsafe-inline')
|
||||
->and(politique($this->get(route('accueil')))['script-src'])->toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
test("chaque script de la page d'inscription porte le nonce de l'en-tête", function () {
|
||||
$reponse = $this->get(route('register'));
|
||||
preg_match("/'nonce-([^']+)'/", $reponse->headers->get('Content-Security-Policy'), $m);
|
||||
|
||||
preg_match_all('/<script\b[^>]*>/', $reponse->getContent(), $scripts);
|
||||
expect($scripts[0])->not->toBeEmpty();
|
||||
foreach ($scripts[0] as $balise) {
|
||||
expect($balise)->toContain("nonce=\"{$m[1]}\"");
|
||||
}
|
||||
});
|
||||
|
||||
test('le rapport de violation est accepté sans jeton CSRF', function () {
|
||||
$this->call('POST', route('csp.rapport'), [], [], [], ['CONTENT_TYPE' => 'application/csp-report'],
|
||||
json_encode(['csp-report' => ['document-uri' => 'https://syllabuslab.fr/eleve?x=1', 'violated-directive' => 'script-src', 'blocked-uri' => 'inline']]))
|
||||
->assertNoContent();
|
||||
});
|
||||
|
|
@ -9,8 +9,10 @@
|
|||
|
||||
Usage (sur le serveur, après preparer-parcours.php) :
|
||||
sudo -u www-data python3 tests/Reels/parcours-lab.py
|
||||
(PARCOURS_BASE=… pour viser une autre adresse que https://syllabuslab.fr)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
|
@ -18,7 +20,9 @@ import time
|
|||
import requests
|
||||
import websocket
|
||||
|
||||
BASE = "http://127.0.0.1"
|
||||
# Depuis l'étape 12 : par le domaine, en HTTPS (cookies « secure »).
|
||||
BASE = os.environ.get("PARCOURS_BASE", "https://syllabuslab.fr")
|
||||
BASE_WS = re.sub(r"^http", "ws", BASE)
|
||||
ids = json.load(open("/tmp/parcours-identifiants.json"))
|
||||
seance = ids["seance_id"]
|
||||
|
||||
|
|
@ -70,7 +74,7 @@ consoles = {}
|
|||
for url, nom in zip(re.findall(r"fetch\('(http[^']+/console)'", page), re.findall(r'data-onglet="\d+"[^>]*>([^<]+)<', page)):
|
||||
url = re.sub(r"^https?://[^/]+", BASE, url)
|
||||
jeton = eleve.post(url, headers={"X-CSRF-TOKEN": csrf}).json()["jeton"]
|
||||
consoles[nom] = websocket.create_connection(f"ws://127.0.0.1/console/{jeton}", timeout=10)
|
||||
consoles[nom] = websocket.create_connection(f"{BASE_WS}/console/{jeton}", timeout=10)
|
||||
|
||||
|
||||
def lire_jusqua(ws, motif, delai=15):
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import tailwindcss from '@tailwindcss/vite';
|
|||
export default defineConfig({
|
||||
plugins: [
|
||||
laravel({
|
||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||
input: ['resources/css/app.css', 'resources/js/app.js', 'resources/js/console.js'],
|
||||
refresh: true,
|
||||
fonts: [
|
||||
bunny('Instrument Sans', {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue