ajoute le stockage S3 des devoirs maison (bloc B, dernier chantier)
Dépôt de fichier en plus du texte libre pour les devoirs maison, stocké sur Hetzner Object Storage (jamais sur le disque du serveur), masqué côté élève tant que le bucket n'est pas configuré. Purge RGPD étendue pour supprimer aussi les fichiers d'Object Storage. Ansible lit les identifiants depuis un fichier que seul Adam peut créer sur le serveur (runbook docs/runbooks/stockage-s3.md) ; le bloc B du CLAUDE.md est maintenant complet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
79d4a06fdf
commit
6ea7a1cc16
16 changed files with 1022 additions and 59 deletions
|
|
@ -144,6 +144,50 @@
|
|||
# redémarrage, il garderait l'ancien mot de passe après une rotation.
|
||||
notify: redémarrer horizon
|
||||
|
||||
# Bloc B, stockage S3 (2026-10-14) : fichier créé à la main par Adam sur le
|
||||
# serveur, jamais par Ansible ni par Claude (CLAUDE.md, commandes et
|
||||
# dépenses). Tant qu'il n'existe pas, ce bloc ne touche à rien : le dépôt de
|
||||
# fichier des devoirs reste simplement masqué côté portail
|
||||
# (StockageTravauxEleves::disponible()) — voir docs/runbooks/stockage-s3.md.
|
||||
- name: vérifier si les identifiants S3 existent (étape 10, facultatif)
|
||||
ansible.builtin.stat:
|
||||
path: /etc/syllabus/s3-paillasse.yaml
|
||||
register: portail_s3_stat
|
||||
|
||||
- name: lire les identifiants S3 du portail (étape 10)
|
||||
ansible.builtin.slurp:
|
||||
src: /etc/syllabus/s3-paillasse.yaml
|
||||
register: portail_s3_brut
|
||||
when: portail_s3_stat.stat.exists
|
||||
|
||||
- name: extraire les champs S3
|
||||
ansible.builtin.set_fact:
|
||||
portail_s3_access_key: "{{ portail_s3_contenu | regex_search('access_key:\\s*\"?([^\"\\n]+)', '\\1') | first }}"
|
||||
portail_s3_secret_key: "{{ portail_s3_contenu | regex_search('secret_key:\\s*\"?([^\"\\n]+)', '\\1') | first }}"
|
||||
portail_s3_bucket: "{{ portail_s3_contenu | regex_search('bucket:\\s*\"?([^\"\\n]+)', '\\1') | first }}"
|
||||
portail_s3_region: "{{ portail_s3_contenu | regex_search('region:\\s*\"?([^\"\\n]+)', '\\1') | first }}"
|
||||
portail_s3_endpoint: "{{ portail_s3_contenu | regex_search('endpoint:\\s*\"?([^\"\\n]+)', '\\1') | first }}"
|
||||
vars:
|
||||
portail_s3_contenu: "{{ portail_s3_brut.content | b64decode }}"
|
||||
when: portail_s3_stat.stat.exists
|
||||
no_log: true
|
||||
|
||||
- name: ajouter/mettre à jour les variables S3 dans .env
|
||||
ansible.builtin.lineinfile:
|
||||
path: "{{ portail_dossier_app }}/.env"
|
||||
regexp: "^{{ item.cle }}="
|
||||
line: "{{ item.cle }}={{ item.valeur }}"
|
||||
loop:
|
||||
- { cle: "AWS_ACCESS_KEY_ID", valeur: "{{ portail_s3_access_key }}" }
|
||||
- { cle: "AWS_SECRET_ACCESS_KEY", valeur: "{{ portail_s3_secret_key }}" }
|
||||
- { cle: "AWS_BUCKET", valeur: "{{ portail_s3_bucket }}" }
|
||||
- { cle: "AWS_DEFAULT_REGION", valeur: "{{ portail_s3_region }}" }
|
||||
- { cle: "AWS_ENDPOINT", valeur: "{{ portail_s3_endpoint }}" }
|
||||
- { cle: "AWS_USE_PATH_STYLE_ENDPOINT", valeur: "false" }
|
||||
when: portail_s3_stat.stat.exists
|
||||
no_log: true
|
||||
notify: redémarrer horizon
|
||||
|
||||
- name: installer les dépendances JS (npm)
|
||||
ansible.builtin.command:
|
||||
cmd: npm ci
|
||||
|
|
|
|||
|
|
@ -35,3 +35,4 @@ retirer la ligne (et le noter dans le journal) quand c'est fait.
|
|||
| Chat : pas de notification sonore ni de notification navigateur, sondage toutes les 3 s (ouvert) / 15 s (badge) | Suffisant pour un usage en classe ; à revoir si le besoin d'alerte immédiate apparaît. | Si ça gêne en usage réel | 2026-10-14 |
|
||||
| « Mes cours » : un seul lab (08) a un vrai cours.md | Les séquences 01 à 07 n'ont pas de cours rédigé (Adam écrit ce contenu lui-même, voir la banque de quiz). La page reste honnête : vide pour un élève qui n'a pratiqué que ces labs, jamais de contenu inventé pour combler. | Quand Adam rédige les cours | 2026-10-14 |
|
||||
| « Mes notes » : pas de notion de compétence pour les devoirs maison (seulement pour les labs, via `labs.competences`) | Un devoir maison n'est lié à aucune compétence du référentiel ; ses notes sortent sous « Sans compétence associée ». Ajouter un champ compétence(s) sur `Devoir` si besoin. | Si ça gêne en usage réel | 2026-10-14 |
|
||||
| Stockage S3 des devoirs maison : bucket Hetzner Object Storage pas encore créé | Code déployé et vérifié (`Storage::fake('s3')` en test, `disponible() === false` confirmé en réel) mais jamais exercé contre un vrai bucket — pas à Claude de le créer (CLAUDE.md, commandes et dépenses). Le dépôt de fichier reste masqué côté élève jusque-là, texte libre toujours disponible. Marche à suivre : `docs/runbooks/stockage-s3.md`. | Quand Adam crée le bucket | 2026-10-14 |
|
||||
|
|
|
|||
|
|
@ -1150,3 +1150,28 @@ Deux fois dans ce chantier, mesurer plutôt que deviner a changé la décision :
|
|||
**Suite**
|
||||
|
||||
Dernier point du bloc B : stockage S3 des travaux d'élèves (Hetzner Object Storage, avec versioning).
|
||||
|
||||
## 2026-10-14 — Stockage S3 des devoirs maison (bloc B, dernier chantier)
|
||||
|
||||
Dernier point du bloc B annoncé par le CLAUDE.md : dépôt de fichier pour les devoirs maison, en plus de la réponse en texte libre déjà existante, stocké sur Hetzner Object Storage (jamais sur le disque du serveur).
|
||||
|
||||
**Fait**
|
||||
|
||||
- Migration : `reponse` devient facultative sur `rendus_devoirs`, trois colonnes ajoutées (`fichier_chemin`, `fichier_nom_original`, `fichier_taille_octets`) — un rendu a désormais au moins l'un des deux, texte ou fichier.
|
||||
- `App\Services\Stockage\StockageTravauxEleves` : stocke/supprime sur le disque `s3` (`league/flysystem-aws-s3-v3`), lien de téléchargement temporaire (5 min), jamais le fichier servi en clair par le portail. `disponible()` renvoie `false` tant que `AWS_BUCKET` n'est pas renseigné — le bouton de dépôt reste alors simplement masqué côté élève (CLAUDE.md : pas de fonction qui a l'air de marcher sans vraiment marcher).
|
||||
- `DevoirEleveController::rendre()` : validation « l'un ou l'autre » (`required_without`), 10 Mo max, extensions limitées (pdf, doc(x), odt, txt, images, zip), 503 si un fichier est envoyé alors que le disque n'est pas configuré.
|
||||
- Bug trouvé et corrigé avant même de tester (relecture de mon propre code) : la première version ne supprimait l'ancien fichier d'Object Storage que par comparaison de chemins, ce qui aurait effacé un fichier déjà déposé à chaque renvoi en texte seul. Corrigé : la suppression de l'ancien fichier n'a lieu que si un *nouveau* fichier est vraiment envoyé dans la requête.
|
||||
- Téléchargement côté prof : `DevoirController::telechargerRendu`, redirige vers un lien signé temporaire, réservé au prof propriétaire de la classe.
|
||||
- Purge RGPD (`classes:purger-archivees`) étendue : les fichiers d'Object Storage des devoirs de la classe sont supprimés avant la suppression en base (la cascade en base ne connaît pas Object Storage).
|
||||
- Ansible (`roles/portail/tasks/03-code.yml`) : bloc conditionnel qui lit `/etc/syllabus/s3-paillasse.yaml` s'il existe et écrit les variables `AWS_*` dans `.env` — fichier à créer par Adam lui-même sur le serveur (jamais par Claude, CLAUDE.md « commandes et dépenses »), rien ne change tant qu'il n'existe pas. Runbook dédié : `docs/runbooks/stockage-s3.md`.
|
||||
- 9 tests Pest (`Storage::fake('s3')`) : dépôt masqué/refusé sans bucket configuré, fichier seul sans texte, validation (ni l'un ni l'autre, taille, extension), remplacement supprime l'ancien mais un renvoi texte seul le conserve, téléchargement réservé au prof propriétaire, affichage côté prof, suppression lors de la purge RGPD. 125 tests au total, tous verts ; Pint et Larastan (niveau 6) sans erreur sur tout le projet.
|
||||
|
||||
**Comprendre**
|
||||
|
||||
Le dernier bug (suppression accidentelle d'un fichier conservé) est le genre d'erreur qu'un test aurait aussi bien pu laisser passer si je ne l'avais écrit qu'après coup en pensant au cas « remplacement » et en oubliant le cas « renvoi texte seul » : relire sa propre logique de décision (« dans quels cas est-ce qu'on supprime ? ») avant d'écrire le test, pas seulement après, aide à voir le cas manquant.
|
||||
|
||||
**Vérifié en réel** : déployé sur le serveur, migration appliquée, bloc Ansible S3 confirmé sans effet tant que `/etc/syllabus/s3-paillasse.yaml` n'existe pas (aucune variable `AWS_` dans `.env`), `StockageTravauxEleves::disponible()` renvoie bien `false` en production (vérifié par tinker, compte jetable supprimé ensuite). Le dépôt réel contre un vrai bucket Hetzner ne pourra être vérifié qu'une fois le bucket créé par Adam (hors de portée de Claude) — couvert pour l'instant par les tests contre un disque S3 factice.
|
||||
|
||||
**Suite**
|
||||
|
||||
Le bloc B du CLAUDE.md est maintenant complet (API publique, cache Redis, optimisation MariaDB, stockage S3). La suite reste au choix d'Adam : créer le bucket S3 (runbook ci-dessus), avancer sur le bloc C, ou organiser le pilote de la semaine 8 du bloc A.
|
||||
|
|
|
|||
83
docs/runbooks/stockage-s3.md
Normal file
83
docs/runbooks/stockage-s3.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Runbook — Stockage S3 des devoirs maison
|
||||
|
||||
Les fichiers que les élèves joignent à leurs devoirs maison sont stockés sur
|
||||
Hetzner Object Storage (S3-compatible), jamais sur le disque du serveur
|
||||
(`app/Services/Stockage/StockageTravauxEleves.php`). Tant que le bucket n'est
|
||||
pas configuré, le bouton de dépôt de fichier reste simplement masqué côté
|
||||
élève (texte libre toujours disponible) — rien ne casse.
|
||||
|
||||
Claude ne peut ni créer le bucket ni générer les clés (CLAUDE.md : commandes
|
||||
et dépenses jamais par Claude). Cette étape est entièrement à faire par Adam.
|
||||
|
||||
## 1. Créer le bucket (console Hetzner)
|
||||
|
||||
Dans le **projet Hetzner dédié à Paillasse** (jamais `tripoli-cloud`) :
|
||||
|
||||
1. Object Storage → créer un bucket, région au choix (ex. `fsn1`).
|
||||
2. Activer le versioning sur le bucket (un fichier écrasé reste récupérable
|
||||
quelques temps).
|
||||
3. Générer une paire de clés d'accès **scopée à ce bucket seulement** (pas
|
||||
une clé avec accès à tout le compte).
|
||||
|
||||
Vérifier les noms exacts des options dans la console/doc Hetzner au moment de
|
||||
le faire : l'interface peut avoir changé depuis l'écriture de ce runbook.
|
||||
|
||||
## 2. Créer le fichier de secrets sur le serveur
|
||||
|
||||
Sur `163.172.4.195`, jamais dans Git, jamais collé dans une conversation avec
|
||||
Claude :
|
||||
|
||||
```
|
||||
ssh paillasse
|
||||
sudo mkdir -p /etc/syllabus
|
||||
sudo tee /etc/syllabus/s3-paillasse.yaml > /dev/null <<'EOF'
|
||||
access_key: "COLLER_LA_CLE_ICI"
|
||||
secret_key: "COLLER_LE_SECRET_ICI"
|
||||
bucket: "nom-du-bucket"
|
||||
region: "fsn1"
|
||||
endpoint: "https://fsn1.your-objectstorage.com"
|
||||
EOF
|
||||
sudo chmod 600 /etc/syllabus/s3-paillasse.yaml
|
||||
sudo chown root:root /etc/syllabus/s3-paillasse.yaml
|
||||
```
|
||||
|
||||
Adapter `region`/`endpoint` à ce que la console Hetzner affiche pour le
|
||||
bucket créé.
|
||||
|
||||
## 3. Déployer
|
||||
|
||||
Depuis le Mac :
|
||||
|
||||
```
|
||||
cd ansible && script -q /dev/null ansible-playbook portail.yml -i inventory.ini
|
||||
```
|
||||
|
||||
Le rôle `portail` (`roles/portail/tasks/03-code.yml`) lit ce fichier s'il
|
||||
existe, écrit `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_BUCKET`/
|
||||
`AWS_DEFAULT_REGION`/`AWS_ENDPOINT` dans `.env` et redémarre Horizon. S'il
|
||||
n'existe pas encore, ce bloc ne fait rien.
|
||||
|
||||
## 4. Vérifier
|
||||
|
||||
```
|
||||
cd /var/www/portail
|
||||
sudo -u www-data HOME=/tmp php8.3 artisan tinker \
|
||||
--execute="echo App\Services\Stockage\StockageTravauxEleves::disponible() ? 'ok' : 'non configure';"
|
||||
```
|
||||
|
||||
Puis, en élève de test, ouvrir un devoir : le champ « Joindre un fichier »
|
||||
doit apparaître. Déposer un petit fichier, vérifier côté prof qu'il apparaît
|
||||
et se télécharge. Supprimer le compte de test ensuite.
|
||||
|
||||
## RGPD
|
||||
|
||||
Un fichier déposé est supprimé d'Object Storage dès que la classe qui le
|
||||
contient est purgée (`classes:purger-archivees`, 90 jours après archivage) —
|
||||
voir `app/Console/Commands/PurgerClassesArchivees.php`.
|
||||
|
||||
## Rotation des clés
|
||||
|
||||
Même logique que pour le compte de service OpenStack
|
||||
([runbook dédié](compte-service-openstack.md)) : régénérer les clés dans la
|
||||
console Hetzner, remplacer `access_key`/`secret_key` dans
|
||||
`/etc/syllabus/s3-paillasse.yaml`, redéployer (étape 3).
|
||||
|
|
@ -3,7 +3,9 @@
|
|||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Classe;
|
||||
use App\Models\RenduDevoir;
|
||||
use App\Services\CacheTableauDeBordProf;
|
||||
use App\Services\Stockage\StockageTravauxEleves;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -44,6 +46,13 @@ class PurgerClassesArchivees extends Command
|
|||
continue;
|
||||
}
|
||||
|
||||
// Les fichiers d'Object Storage ne sont pas liés par une clé
|
||||
// étrangère : à supprimer nous-mêmes avant la cascade en base.
|
||||
RenduDevoir::whereHas('devoir', fn ($q) => $q->where('classe_id', $classe->id))
|
||||
->whereNotNull('fichier_chemin')
|
||||
->pluck('fichier_chemin')
|
||||
->each(fn (string $chemin) => StockageTravauxEleves::supprimer($chemin));
|
||||
|
||||
DB::transaction(fn () => $classe->delete());
|
||||
CacheTableauDeBordProf::vider($classe->user_id);
|
||||
Log::info('Purge RGPD : classe supprimée', [
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||
use App\Models\Classe;
|
||||
use App\Models\Devoir;
|
||||
use App\Models\RenduDevoir;
|
||||
use App\Services\Stockage\StockageTravauxEleves;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -64,6 +65,16 @@ class DevoirController extends Controller
|
|||
return redirect()->route('mes-classes.devoirs.show', [$classe, $devoir]);
|
||||
}
|
||||
|
||||
public function telechargerRendu(Classe $classe, Devoir $devoir, RenduDevoir $rendu): RedirectResponse
|
||||
{
|
||||
$this->verifierProprietaire($classe);
|
||||
$this->verifierAppartenance($classe, $devoir);
|
||||
abort_if($rendu->devoir_id !== $devoir->id, 404);
|
||||
abort_if($rendu->fichier_chemin === null, 404);
|
||||
|
||||
return redirect()->away(StockageTravauxEleves::urlTemporaire($rendu->fichier_chemin));
|
||||
}
|
||||
|
||||
public function destroy(Classe $classe, Devoir $devoir): RedirectResponse
|
||||
{
|
||||
$this->verifierProprietaire($classe);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||
use App\Models\Devoir;
|
||||
use App\Models\Eleve;
|
||||
use App\Models\RenduDevoir;
|
||||
use App\Services\Stockage\StockageTravauxEleves;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -23,6 +24,7 @@ class DevoirEleveController extends Controller
|
|||
return view('devoirs.eleve', [
|
||||
'devoir' => $devoir,
|
||||
'rendu' => RenduDevoir::where('devoir_id', $devoir->id)->where('eleve_id', $eleve->id)->first(),
|
||||
'depotFichierDisponible' => StockageTravauxEleves::disponible(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -31,12 +33,39 @@ class DevoirEleveController extends Controller
|
|||
$eleve = $this->verifierAppartenance($devoir);
|
||||
abort_if($devoir->echeanceDepassee(), 409, 'L’échéance est dépassée.');
|
||||
|
||||
$donnees = $request->validate(['reponse' => ['required', 'string', 'max:10000']]);
|
||||
$donnees = $request->validate([
|
||||
'reponse' => ['nullable', 'required_without:fichier', 'string', 'max:10000'],
|
||||
'fichier' => [
|
||||
'nullable', 'required_without:reponse', 'file',
|
||||
'max:'.(int) (StockageTravauxEleves::TAILLE_MAX_OCTETS / 1024),
|
||||
'mimes:'.implode(',', StockageTravauxEleves::EXTENSIONS_ACCEPTEES),
|
||||
],
|
||||
]);
|
||||
|
||||
RenduDevoir::updateOrCreate(
|
||||
['devoir_id' => $devoir->id, 'eleve_id' => $eleve->id],
|
||||
['reponse' => $donnees['reponse'], 'rendu_a' => now()]
|
||||
);
|
||||
if ($request->hasFile('fichier') && ! StockageTravauxEleves::disponible()) {
|
||||
abort(503, 'Le dépôt de fichier n’est pas disponible pour le moment.');
|
||||
}
|
||||
|
||||
$ancienRendu = RenduDevoir::where('devoir_id', $devoir->id)->where('eleve_id', $eleve->id)->first();
|
||||
|
||||
$valeurs = ['reponse' => $donnees['reponse'] ?? null, 'rendu_a' => now()];
|
||||
|
||||
// Pas de nouveau fichier : celui déjà rendu (s'il existe) reste en
|
||||
// place, updateOrCreate ne touche pas aux colonnes absentes de $valeurs.
|
||||
if ($request->hasFile('fichier')) {
|
||||
$fichier = StockageTravauxEleves::stocker($request->file('fichier'), (string) $devoir->id, (string) $eleve->id);
|
||||
$valeurs['fichier_chemin'] = $fichier['chemin'];
|
||||
$valeurs['fichier_nom_original'] = $fichier['nom_original'];
|
||||
$valeurs['fichier_taille_octets'] = $fichier['taille_octets'];
|
||||
}
|
||||
|
||||
RenduDevoir::updateOrCreate(['devoir_id' => $devoir->id, 'eleve_id' => $eleve->id], $valeurs);
|
||||
|
||||
// Un nouveau fichier remplace l'ancien : celui-ci n'a plus de raison
|
||||
// d'exister sur Object Storage.
|
||||
if ($request->hasFile('fichier') && $ancienRendu?->fichier_chemin) {
|
||||
StockageTravauxEleves::supprimer($ancienRendu->fichier_chemin);
|
||||
}
|
||||
|
||||
return redirect()->route('devoirs.eleve.show', $devoir)->with('rendu_envoye', true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class RenduDevoir extends Model
|
|||
{
|
||||
protected $table = 'rendus_devoirs';
|
||||
|
||||
protected $fillable = ['devoir_id', 'eleve_id', 'reponse', 'note', 'rendu_a'];
|
||||
protected $fillable = ['devoir_id', 'eleve_id', 'reponse', 'fichier_chemin', 'fichier_nom_original', 'fichier_taille_octets', 'note', 'rendu_a'];
|
||||
|
||||
protected $casts = [
|
||||
'rendu_a' => 'datetime',
|
||||
|
|
@ -27,4 +27,9 @@ class RenduDevoir extends Model
|
|||
{
|
||||
return $this->belongsTo(Eleve::class);
|
||||
}
|
||||
|
||||
public function aUnFichier(): bool
|
||||
{
|
||||
return $this->fichier_chemin !== null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
58
portail/app/Services/Stockage/StockageTravauxEleves.php
Normal file
58
portail/app/Services/Stockage/StockageTravauxEleves.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Stockage;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Stockage des rendus de devoirs maison sur Object Storage (bloc B,
|
||||
* Hetzner, 2026-10-14) : jamais sur le disque du serveur. Le bouton de
|
||||
* dépôt de fichier n'apparaît que si le disque est vraiment configuré
|
||||
* (CLAUDE.md : pas de fonction qui a l'air de marcher sans vraiment
|
||||
* marcher) — tant qu'Adam n'a pas créé le bucket, seul le texte libre
|
||||
* reste proposé.
|
||||
*/
|
||||
class StockageTravauxEleves
|
||||
{
|
||||
public const TAILLE_MAX_OCTETS = 10 * 1024 * 1024; // 10 Mo
|
||||
|
||||
public const EXTENSIONS_ACCEPTEES = ['pdf', 'doc', 'docx', 'odt', 'txt', 'png', 'jpg', 'jpeg', 'zip'];
|
||||
|
||||
public static function disponible(): bool
|
||||
{
|
||||
return filled(config('filesystems.disks.s3.bucket'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{chemin: string, nom_original: string, taille_octets: int}
|
||||
*/
|
||||
public static function stocker(UploadedFile $fichier, string $devoirId, string $eleveId): array
|
||||
{
|
||||
$chemin = $fichier->storeAs(
|
||||
"devoirs/{$devoirId}/{$eleveId}",
|
||||
now()->format('Ymd-His').'-'.Str::random(8).'.'.$fichier->getClientOriginalExtension(),
|
||||
's3'
|
||||
);
|
||||
|
||||
return [
|
||||
'chemin' => $chemin,
|
||||
'nom_original' => $fichier->getClientOriginalName(),
|
||||
'taille_octets' => $fichier->getSize(),
|
||||
];
|
||||
}
|
||||
|
||||
public static function supprimer(?string $chemin): void
|
||||
{
|
||||
if ($chemin !== null) {
|
||||
Storage::disk('s3')->delete($chemin);
|
||||
}
|
||||
}
|
||||
|
||||
/** Lien valable 5 minutes, jamais le fichier servi en clair par le portail. */
|
||||
public static function urlTemporaire(string $chemin): string
|
||||
{
|
||||
return Storage::disk('s3')->temporaryUrl($chemin, now()->addMinutes(5));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,10 +8,12 @@
|
|||
"require": {
|
||||
"php": "^8.3",
|
||||
"bacon/bacon-qr-code": "^3.1",
|
||||
"doctrine/dbal": "^4.5",
|
||||
"laravel/framework": "^13.17",
|
||||
"laravel/horizon": "^5.50",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/tinker": "^3.0",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"symfony/yaml": "^7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
|
|
|
|||
595
portail/composer.lock
generated
595
portail/composer.lock
generated
|
|
@ -4,8 +4,159 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "4f63b39390b44505487b3c5999569f85",
|
||||
"content-hash": "6f5b54180f0d3c00c4708c345a8ac918",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
"version": "v1.2.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/awslabs/aws-crt-php.git",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
|
||||
"yoast/phpunit-polyfills": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "AWS SDK Common Runtime Team",
|
||||
"email": "aws-sdk-common-runtime@amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS Common Runtime for PHP",
|
||||
"homepage": "https://github.com/awslabs/aws-crt-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"crt",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/awslabs/aws-crt-php/issues",
|
||||
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
|
||||
},
|
||||
"time": "2024-10-18T22:15:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-sdk-php",
|
||||
"version": "3.401.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||
"reference": "4cdda6c2d836fa91a8177535530952c96a582f57"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/4cdda6c2d836fa91a8177535530952c96a582f57",
|
||||
"reference": "4cdda6c2d836fa91a8177535530952c96a582f57",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-crt-php": "^1.2.3",
|
||||
"ext-json": "*",
|
||||
"ext-pcre": "*",
|
||||
"ext-simplexml": "*",
|
||||
"guzzlehttp/guzzle": "^7.8.2 || ^8.0",
|
||||
"guzzlehttp/promises": "^2.0.3 || ^3.0",
|
||||
"guzzlehttp/psr7": "^2.6.3 || ^3.0",
|
||||
"mtdowling/jmespath.php": "^2.9.1",
|
||||
"php": ">=8.1",
|
||||
"psr/http-message": "^1.0 || ^2.0",
|
||||
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"andrewsville/php-token-reflection": "^1.4",
|
||||
"aws/aws-php-sns-message-validator": "~1.0",
|
||||
"behat/behat": "~3.0",
|
||||
"composer/composer": "^2.7.8",
|
||||
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
|
||||
"doctrine/cache": "~1.4",
|
||||
"ext-dom": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-sockets": "*",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"psr/cache": "^2.0 || ^3.0",
|
||||
"psr/simple-cache": "^2.0 || ^3.0",
|
||||
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
|
||||
"yoast/phpunit-polyfills": "^2.0"
|
||||
},
|
||||
"suggest": {
|
||||
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
|
||||
"doctrine/cache": "To use the DoctrineCacheAdapter",
|
||||
"ext-curl": "To send requests using cURL",
|
||||
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
|
||||
"ext-pcntl": "To use client-side monitoring",
|
||||
"ext-sockets": "To use client-side monitoring"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Aws\\": "src/"
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"src/data/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Amazon Web Services",
|
||||
"homepage": "https://aws.amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
|
||||
"homepage": "https://aws.amazon.com/sdk-for-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"cloud",
|
||||
"dynamodb",
|
||||
"ec2",
|
||||
"glacier",
|
||||
"s3",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.401.1"
|
||||
},
|
||||
"time": "2026-10-13T18:14:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
"version": "v3.1.1",
|
||||
|
|
@ -320,6 +471,160 @@
|
|||
},
|
||||
"time": "2024-07-08T12:26:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/dbal",
|
||||
"version": "4.5.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/dbal.git",
|
||||
"reference": "c435cd7ea33e17d2a89288caf994cf3d4549b879"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/dbal/zipball/c435cd7ea33e17d2a89288caf994cf3d4549b879",
|
||||
"reference": "c435cd7ea33e17d2a89288caf994cf3d4549b879",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"doctrine/deprecations": "^1.1.5",
|
||||
"php": "^8.2",
|
||||
"psr/cache": "^1|^2|^3",
|
||||
"psr/log": "^1|^2|^3"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "14.0.0",
|
||||
"fig/log-test": "^1",
|
||||
"jetbrains/phpstorm-stubs": "2023.2",
|
||||
"phpstan/phpstan": "2.1.30",
|
||||
"phpstan/phpstan-phpunit": "2.0.7",
|
||||
"phpstan/phpstan-strict-rules": "^2",
|
||||
"phpunit/phpunit": "11.5.56",
|
||||
"slevomat/coding-standard": "8.31.1",
|
||||
"squizlabs/php_codesniffer": "4.0.4",
|
||||
"symfony/cache": "^6.3.8|^7.0|^8.0",
|
||||
"symfony/console": "^5.4|^6.3|^7.0|^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"symfony/console": "For helpful console commands such as SQL execution and import of files."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Doctrine\\DBAL\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Guilherme Blanco",
|
||||
"email": "guilhermeblanco@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Roman Borschel",
|
||||
"email": "roman@code-factory.org"
|
||||
},
|
||||
{
|
||||
"name": "Benjamin Eberlei",
|
||||
"email": "kontakt@beberlei.de"
|
||||
},
|
||||
{
|
||||
"name": "Jonathan Wage",
|
||||
"email": "jonwage@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.",
|
||||
"homepage": "https://www.doctrine-project.org/projects/dbal.html",
|
||||
"keywords": [
|
||||
"abstraction",
|
||||
"database",
|
||||
"db2",
|
||||
"dbal",
|
||||
"mariadb",
|
||||
"mssql",
|
||||
"mysql",
|
||||
"oci8",
|
||||
"oracle",
|
||||
"pdo",
|
||||
"pgsql",
|
||||
"postgresql",
|
||||
"queryobject",
|
||||
"sasql",
|
||||
"sql",
|
||||
"sqlite",
|
||||
"sqlserver",
|
||||
"sqlsrv"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/dbal/issues",
|
||||
"source": "https://github.com/doctrine/dbal/tree/4.5.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.doctrine-project.org/sponsorship.html",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/phpdoctrine",
|
||||
"type": "patreon"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-24T16:11:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/deprecations",
|
||||
"version": "1.1.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/deprecations.git",
|
||||
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
|
||||
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpunit/phpunit": "<=7.5 || >=14"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^9 || ^12 || ^14",
|
||||
"phpstan/phpstan": "1.4.10 || 2.1.30",
|
||||
"phpstan/phpstan-phpunit": "^1.0 || ^2",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
|
||||
"psr/log": "^1 || ^2 || ^3"
|
||||
},
|
||||
"suggest": {
|
||||
"psr/log": "Allows logging deprecations via PSR-3 logger implementation"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Doctrine\\Deprecations\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
|
||||
"homepage": "https://www.doctrine-project.org/",
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/deprecations/issues",
|
||||
"source": "https://github.com/doctrine/deprecations/tree/1.1.6"
|
||||
},
|
||||
"time": "2026-02-07T07:09:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/inflector",
|
||||
"version": "2.1.0",
|
||||
|
|
@ -2053,6 +2358,61 @@
|
|||
},
|
||||
"time": "2026-09-02T08:00:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
"version": "3.35.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
|
||||
"reference": "b03780cb97585ee7e48977f40ed599b33b751634"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/b03780cb97585ee7e48977f40ed599b33b751634",
|
||||
"reference": "b03780cb97585ee7e48977f40ed599b33b751634",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-sdk-php": "^3.371.5",
|
||||
"league/flysystem": "^3.10.0",
|
||||
"league/mime-type-detection": "^1.0.0",
|
||||
"php": "^8.0.2"
|
||||
},
|
||||
"conflict": {
|
||||
"guzzlehttp/guzzle": "<7.0",
|
||||
"guzzlehttp/ringphp": "<1.1.1"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Flysystem\\AwsS3V3\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Frank de Jonge",
|
||||
"email": "info@frankdejonge.nl"
|
||||
}
|
||||
],
|
||||
"description": "AWS S3 filesystem adapter for Flysystem.",
|
||||
"keywords": [
|
||||
"Flysystem",
|
||||
"aws",
|
||||
"file",
|
||||
"files",
|
||||
"filesystem",
|
||||
"s3",
|
||||
"storage"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.3"
|
||||
},
|
||||
"time": "2026-08-08T16:19:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-local",
|
||||
"version": "3.35.3",
|
||||
|
|
@ -2445,6 +2805,72 @@
|
|||
],
|
||||
"time": "2026-09-29T19:21:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mtdowling/jmespath.php",
|
||||
"version": "2.9.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jmespath/jmespath.php.git",
|
||||
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
|
||||
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"symfony/polyfill-mbstring": "^1.17"
|
||||
},
|
||||
"require-dev": {
|
||||
"composer/xdebug-handler": "^3.0.3",
|
||||
"phpunit/phpunit": "^8.5.52"
|
||||
},
|
||||
"bin": [
|
||||
"bin/jp.php"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.9-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/JmesPath.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"JmesPath\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Michael Dowling",
|
||||
"email": "mtdowling@gmail.com",
|
||||
"homepage": "https://github.com/mtdowling"
|
||||
}
|
||||
],
|
||||
"description": "Declaratively specify how to extract elements from a JSON document",
|
||||
"keywords": [
|
||||
"json",
|
||||
"jsonpath"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/jmespath/jmespath.php/issues",
|
||||
"source": "https://github.com/jmespath/jmespath.php/tree/2.9.2"
|
||||
},
|
||||
"time": "2026-07-06T18:56:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.14.2",
|
||||
|
|
@ -2927,6 +3353,55 @@
|
|||
],
|
||||
"time": "2026-08-24T00:54:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
"version": "3.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/cache.git",
|
||||
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
|
||||
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Cache\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for caching libraries",
|
||||
"keywords": [
|
||||
"cache",
|
||||
"psr",
|
||||
"psr-6"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/cache/tree/3.0.0"
|
||||
},
|
||||
"time": "2021-02-03T23:26:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/clock",
|
||||
"version": "1.0.0",
|
||||
|
|
@ -4135,6 +4610,76 @@
|
|||
],
|
||||
"time": "2026-06-05T06:23:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/filesystem",
|
||||
"version": "v7.4.21",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/filesystem.git",
|
||||
"reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/filesystem/zipball/fd0e3462be6d32a0d130e8423e4940a7688162d8",
|
||||
"reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/polyfill-ctype": "~1.8",
|
||||
"symfony/polyfill-mbstring": "~1.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/process": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Filesystem\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides basic utilities for the filesystem",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/filesystem/tree/v7.4.21"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-06T20:05:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/finder",
|
||||
"version": "v7.4.20",
|
||||
|
|
@ -6767,54 +7312,6 @@
|
|||
],
|
||||
"time": "2024-05-06T16:37:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/deprecations",
|
||||
"version": "1.1.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/deprecations.git",
|
||||
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
|
||||
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpunit/phpunit": "<=7.5 || >=14"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^9 || ^12 || ^14",
|
||||
"phpstan/phpstan": "1.4.10 || 2.1.30",
|
||||
"phpstan/phpstan-phpunit": "^1.0 || ^2",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
|
||||
"psr/log": "^1 || ^2 || ^3"
|
||||
},
|
||||
"suggest": {
|
||||
"psr/log": "Allows logging deprecations via PSR-3 logger implementation"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Doctrine\\Deprecations\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
|
||||
"homepage": "https://www.doctrine-project.org/",
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/deprecations/issues",
|
||||
"source": "https://github.com/doctrine/deprecations/tree/1.1.6"
|
||||
},
|
||||
"time": "2026-02-07T07:09:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "fakerphp/faker",
|
||||
"version": "v1.24.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Dépôt de fichier pour les devoirs maison (bloc B, stockage S3, 2026-10-14) :
|
||||
* en plus du texte libre, pas à sa place — un rendu a au moins l'un des
|
||||
* deux. Le fichier n'est jamais sur le serveur, seulement son chemin sur
|
||||
* Object Storage (Hetzner, disque « s3 »).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('rendus_devoirs', function (Blueprint $table) {
|
||||
$table->text('reponse')->nullable()->change();
|
||||
$table->string('fichier_chemin')->nullable()->after('reponse');
|
||||
$table->string('fichier_nom_original')->nullable()->after('fichier_chemin');
|
||||
$table->unsignedInteger('fichier_taille_octets')->nullable()->after('fichier_nom_original');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('rendus_devoirs', function (Blueprint $table) {
|
||||
$table->dropColumn(['fichier_chemin', 'fichier_nom_original', 'fichier_taille_octets']);
|
||||
$table->text('reponse')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -26,15 +26,27 @@
|
|||
<p class="mt-2 font-semibold text-[#5f6368]">Rendu le {{ $rendu->rendu_a->timezone(config('app.fuseau_utilisateurs'))->translatedFormat('d/m/Y H:i') }}. Un nouvel envoi remplace celui-ci{{ $devoir->echeanceDepassee() ? '' : ', jusqu\'à l\'échéance' }}.</p>
|
||||
@endif
|
||||
@error('reponse')<p class="mt-2 font-bold text-[#A63D2A]">{{ $message }}</p>@enderror
|
||||
@error('fichier')<p class="mt-2 font-bold text-[#A63D2A]">{{ $message }}</p>@enderror
|
||||
@if ($rendu && $rendu->aUnFichier())
|
||||
<p class="mt-2 font-semibold text-[#5f6368]">Fichier déjà envoyé : {{ $rendu->fichier_nom_original }}</p>
|
||||
@endif
|
||||
@if (! $devoir->echeanceDepassee())
|
||||
<form action="{{ route('devoirs.eleve.rendre', $devoir) }}" method="post" class="mt-4 grid gap-3">
|
||||
<form action="{{ route('devoirs.eleve.rendre', $devoir) }}" method="post" enctype="multipart/form-data" class="mt-4 grid gap-3">
|
||||
@csrf
|
||||
<textarea name="reponse" required rows="8" class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold">{{ old('reponse', $rendu->reponse ?? '') }}</textarea>
|
||||
<textarea name="reponse" rows="8" class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold" placeholder="Ta réponse (facultatif si tu joins un fichier)">{{ old('reponse', $rendu->reponse ?? '') }}</textarea>
|
||||
@if ($depotFichierDisponible)
|
||||
<label class="font-bold">
|
||||
Joindre un fichier (facultatif)
|
||||
<input type="file" name="fichier" class="mt-1 block font-semibold">
|
||||
</label>
|
||||
@endif
|
||||
<button type="submit" class="mt-2 inline-flex h-11 w-fit items-center rounded-full bg-black px-5 font-bold text-white">Envoyer</button>
|
||||
</form>
|
||||
@elseif ($rendu)
|
||||
@if ($rendu->reponse)
|
||||
<p class="mt-4 whitespace-pre-wrap font-semibold">{{ $rendu->reponse }}</p>
|
||||
@endif
|
||||
@endif
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,14 @@
|
|||
@endif
|
||||
</div>
|
||||
@if ($rendu)
|
||||
@if ($rendu->reponse)
|
||||
<p class="mt-3 whitespace-pre-wrap rounded-xl border border-[#e3e5e8] bg-[#f8f9fa] p-4 font-semibold">{{ $rendu->reponse }}</p>
|
||||
@endif
|
||||
@if ($rendu->aUnFichier())
|
||||
<a href="{{ route('mes-classes.devoirs.telecharger', [$classe, $devoir, $rendu]) }}" class="mt-3 inline-flex items-center gap-2 rounded-xl border border-[#e3e5e8] bg-[#f8f9fa] px-4 py-2 font-bold underline underline-offset-4">
|
||||
Fichier : {{ $rendu->fichier_nom_original }} ({{ number_format($rendu->fichier_taille_octets / 1024, 0) }} Ko)
|
||||
</a>
|
||||
@endif
|
||||
<form action="{{ route('mes-classes.devoirs.noter', [$classe, $devoir, $rendu]) }}" method="post" class="mt-3 flex items-center gap-2">
|
||||
@csrf
|
||||
<label class="font-bold">Note <input type="number" name="note" min="0" max="20" value="{{ $rendu->note }}" class="w-20 rounded-xl border border-[#c4c7c5] bg-white px-2 py-1 font-semibold"> / 20</label>
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () {
|
|||
Route::get('/mes-classes/{classe}/devoirs/{devoir}', [DevoirController::class, 'show'])->name('mes-classes.devoirs.show');
|
||||
Route::post('/mes-classes/{classe}/devoirs/{devoir}/supprimer', [DevoirController::class, 'destroy'])->name('mes-classes.devoirs.destroy');
|
||||
Route::post('/mes-classes/{classe}/devoirs/{devoir}/rendus/{rendu}/noter', [DevoirController::class, 'noter'])->name('mes-classes.devoirs.noter');
|
||||
Route::get('/mes-classes/{classe}/devoirs/{devoir}/rendus/{rendu}/telecharger', [DevoirController::class, 'telechargerRendu'])->name('mes-classes.devoirs.telecharger');
|
||||
|
||||
// Séparation vitrine/application (2026-10-13) : Ma classe (annonces),
|
||||
// Résultats (observations), Séances en direct, Bibliothèque, chat.
|
||||
|
|
|
|||
147
portail/tests/Feature/StockageDevoirsTest.php
Normal file
147
portail/tests/Feature/StockageDevoirsTest.php
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Classe;
|
||||
use App\Models\Devoir;
|
||||
use App\Models\Eleve;
|
||||
use App\Models\RenduDevoir;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/** Active le disque s3 factice et le fait passer pour configuré. */
|
||||
function avecDisqueS3Factice(): void
|
||||
{
|
||||
config(['filesystems.disks.s3.bucket' => 'bucket-de-test']);
|
||||
Storage::fake('s3');
|
||||
}
|
||||
|
||||
test('tant que le bucket s3 n’est pas configuré, le dépôt de fichier est masqué et refusé', function () {
|
||||
$classe = Classe::factory()->create();
|
||||
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
|
||||
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
|
||||
|
||||
eleve($this, $eleve)->get(route('devoirs.eleve.show', $devoir))->assertOk()->assertDontSee('Joindre un fichier');
|
||||
|
||||
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
|
||||
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
|
||||
])->assertStatus(503);
|
||||
});
|
||||
|
||||
test('un élève peut rendre un devoir avec un fichier seul, sans texte', function () {
|
||||
avecDisqueS3Factice();
|
||||
$classe = Classe::factory()->create();
|
||||
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
|
||||
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
|
||||
|
||||
eleve($this, $eleve)->get(route('devoirs.eleve.show', $devoir))->assertOk()->assertSee('Joindre un fichier');
|
||||
|
||||
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
|
||||
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
|
||||
])->assertRedirect();
|
||||
|
||||
$rendu = RenduDevoir::where('devoir_id', $devoir->id)->where('eleve_id', $eleve->id)->first();
|
||||
expect($rendu)->not->toBeNull();
|
||||
expect($rendu->reponse)->toBeNull();
|
||||
expect($rendu->aUnFichier())->toBeTrue();
|
||||
expect($rendu->fichier_nom_original)->toBe('devoir.pdf');
|
||||
Storage::disk('s3')->assertExists($rendu->fichier_chemin);
|
||||
});
|
||||
|
||||
test('un rendu sans réponse ni fichier est refusé', function () {
|
||||
avecDisqueS3Factice();
|
||||
$classe = Classe::factory()->create();
|
||||
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
|
||||
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
|
||||
|
||||
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [])->assertSessionHasErrors(['reponse', 'fichier']);
|
||||
expect(RenduDevoir::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('un fichier trop lourd ou d’une extension refusée est rejeté', function () {
|
||||
avecDisqueS3Factice();
|
||||
$classe = Classe::factory()->create();
|
||||
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
|
||||
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
|
||||
|
||||
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
|
||||
'fichier' => UploadedFile::fake()->create('trop-gros.pdf', 11 * 1024, 'application/pdf'),
|
||||
])->assertSessionHasErrors('fichier');
|
||||
|
||||
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
|
||||
'fichier' => UploadedFile::fake()->create('script.exe', 10, 'application/x-msdownload'),
|
||||
])->assertSessionHasErrors('fichier');
|
||||
|
||||
expect(RenduDevoir::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('remplacer son fichier supprime l’ancien de l’object storage, un nouvel envoi en texte seul le conserve', function () {
|
||||
avecDisqueS3Factice();
|
||||
$classe = Classe::factory()->create();
|
||||
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
|
||||
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
|
||||
|
||||
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
|
||||
'fichier' => UploadedFile::fake()->create('premier.pdf', 100, 'application/pdf'),
|
||||
])->assertRedirect();
|
||||
$cheminPremier = RenduDevoir::first()->fichier_chemin;
|
||||
Storage::disk('s3')->assertExists($cheminPremier);
|
||||
|
||||
// Un nouvel envoi en texte seul (pas de nouveau fichier) garde l'ancien fichier.
|
||||
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), ['reponse' => 'Je précise ma réponse'])->assertRedirect();
|
||||
$rendu = RenduDevoir::first();
|
||||
expect($rendu->fichier_chemin)->toBe($cheminPremier)->and($rendu->reponse)->toBe('Je précise ma réponse');
|
||||
Storage::disk('s3')->assertExists($cheminPremier);
|
||||
|
||||
// Un nouveau fichier remplace l'ancien, qui est supprimé de l'object storage.
|
||||
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
|
||||
'fichier' => UploadedFile::fake()->create('second.pdf', 100, 'application/pdf'),
|
||||
])->assertRedirect();
|
||||
Storage::disk('s3')->assertMissing($cheminPremier);
|
||||
expect(RenduDevoir::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('seul le prof propriétaire peut télécharger le fichier rendu par un élève', function () {
|
||||
avecDisqueS3Factice();
|
||||
$classe = Classe::factory()->create();
|
||||
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
|
||||
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
|
||||
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
|
||||
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
|
||||
])->assertRedirect();
|
||||
$rendu = RenduDevoir::first();
|
||||
|
||||
commeProf($this, $classe->prof)->get(route('mes-classes.devoirs.telecharger', [$classe, $devoir, $rendu]))->assertRedirect();
|
||||
|
||||
$autreClasse = Classe::factory()->create();
|
||||
commeProf($this, $autreClasse->prof)->get(route('mes-classes.devoirs.telecharger', [$classe, $devoir, $rendu]))->assertForbidden();
|
||||
});
|
||||
|
||||
test('le prof voit le fichier déposé par un élève dans la page du devoir', function () {
|
||||
avecDisqueS3Factice();
|
||||
$classe = Classe::factory()->create();
|
||||
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
|
||||
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
|
||||
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
|
||||
'fichier' => UploadedFile::fake()->create('rapport-reseau.pdf', 100, 'application/pdf'),
|
||||
])->assertRedirect();
|
||||
|
||||
commeProf($this, $classe->prof)->get(route('mes-classes.devoirs.show', [$classe, $devoir]))
|
||||
->assertSee('rapport-reseau.pdf');
|
||||
});
|
||||
|
||||
test('purger une classe archivée supprime aussi les fichiers de ses rendus sur l’object storage', function () {
|
||||
avecDisqueS3Factice();
|
||||
$classe = Classe::factory()->create();
|
||||
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
|
||||
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
|
||||
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
|
||||
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
|
||||
])->assertRedirect();
|
||||
$chemin = RenduDevoir::first()->fichier_chemin;
|
||||
Storage::disk('s3')->assertExists($chemin);
|
||||
|
||||
$classe->forceFill(['archivee_a' => now()->subDays(91)])->save();
|
||||
$this->artisan('classes:purger-archivees');
|
||||
|
||||
Storage::disk('s3')->assertMissing($chemin);
|
||||
expect(RenduDevoir::count())->toBe(0);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue