Compare commits
25 Commits
454767aa58
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a1b37c453d | |||
| 96b598c6f4 | |||
| 2984b9f410 | |||
| 507d77246c | |||
| dede70f70f | |||
| 54f2c0e9ab | |||
| e8c6733b40 | |||
| 63240f2e52 | |||
| ab4f2cb403 | |||
| 4d560a03cc | |||
| b568bc63a9 | |||
| 1e6557729f | |||
| ad6b6eb047 | |||
| 3b7b527462 | |||
| 79c902cf32 | |||
| e5e6f72f92 | |||
| de45e62d20 | |||
| e2b68f2d68 | |||
| 751af7adb4 | |||
| 8ab2d988fe | |||
| 1fa3806556 | |||
| 6e387a66ea | |||
| 1817a697ae | |||
| 916c4587f6 | |||
| 33e25a8b8e |
+29
-7
@@ -1,4 +1,8 @@
|
||||
APP_NAME=Laravel
|
||||
# --- Application ---
|
||||
# En production : APP_ENV=production et APP_DEBUG=false (ne jamais exposer les
|
||||
# traces d'erreur / la configuration au public). APP_KEY doit être généré via
|
||||
# `php artisan key:generate` et rester secret (chiffrement des sessions/cookies).
|
||||
APP_NAME="(RE)Sources Relationnelles"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
@@ -20,15 +24,18 @@ LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
# --- Base de données ---
|
||||
# En production, utiliser MySQL/MariaDB (données ministérielles) plutôt que SQLite.
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=ressources_relationnel
|
||||
DB_USERNAME=changeme
|
||||
DB_PASSWORD=changeme
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
# En production, passer à true pour chiffrer les données de session (RGPD).
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
@@ -62,4 +69,19 @@ AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
# --- Gitea (tickets de suggestion créés depuis le site) ---
|
||||
# Token d'accès Gitea avec le scope "write:issue" (Paramètres > Applications > Générer un token).
|
||||
# À garder secret (ne jamais committer la vraie valeur).
|
||||
GITEA_URL=https://gitea.sam-coffre.duckdns.org
|
||||
GITEA_TOKEN=
|
||||
GITEA_REPO=Groupe1/Ressources-relationnel
|
||||
|
||||
# --- Applications mobiles (page /telecharger) ---
|
||||
# Laisser vide pour servir les fichiers de public/downloads/ (.apk / .ipa),
|
||||
# ou renseigner un lien de store / TestFlight (prioritaire sur le fichier local).
|
||||
MOBILE_ANDROID_URL=
|
||||
MOBILE_IOS_URL=
|
||||
MOBILE_ANDROID_VERSION=1.0.0
|
||||
MOBILE_IOS_VERSION=1.0.0
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
name: CI - Intégration continue
|
||||
|
||||
# Intégration continue : à chaque push et chaque pull request, on vérifie
|
||||
# automatiquement la qualité du code (Laravel Pint) puis on exécute les tests
|
||||
# automatisés (PHPUnit). Un déploiement (voir deploy.yml) ne doit se faire que
|
||||
# si cette étape est au vert.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
quality-and-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Jetons SonarQube, définis dans les secrets du dépôt Gitea.
|
||||
# Exposés au niveau du job pour pouvoir conditionner l'étape d'analyse
|
||||
# (elle ne s'exécute que si un jeton est configuré).
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
|
||||
steps:
|
||||
- name: Récupération du code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Historique complet : SonarQube en a besoin pour dater le code
|
||||
# et calculer correctement le "nouveau code".
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Installation de PHP 8.2
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: pdo_mysql, pdo_sqlite, sqlite3, mbstring, exif, pcntl, bcmath, gd
|
||||
# pcov : moteur de couverture rapide, requis pour le rapport SonarQube.
|
||||
coverage: pcov
|
||||
|
||||
- name: Cache des dépendances Composer
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: vendor
|
||||
key: composer-${{ hashFiles('composer.lock') }}
|
||||
|
||||
- name: Installation des dépendances
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Préparation de l'environnement de test
|
||||
run: |
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
|
||||
- name: Vérification du style de code (Laravel Pint)
|
||||
run: vendor/bin/pint --test
|
||||
|
||||
- name: Analyse statique (Larastan / PHPStan)
|
||||
run: vendor/bin/phpstan analyse --configuration=phpstan.neon --memory-limit=512M --no-progress
|
||||
|
||||
- name: Exécution des tests + couverture (PHPUnit)
|
||||
# phpunit.xml définit déjà l'environnement de test (sqlite en mémoire).
|
||||
# On génère le rapport de couverture (clover) et le rapport de tests
|
||||
# (junit) consommés ensuite par SonarQube.
|
||||
run: |
|
||||
mkdir -p coverage
|
||||
vendor/bin/phpunit \
|
||||
--coverage-clover=coverage/clover.xml \
|
||||
--log-junit=coverage/junit.xml
|
||||
|
||||
- name: Analyse SonarQube
|
||||
# Ne s'exécute que si le secret SONAR_TOKEN est configuré dans Gitea,
|
||||
# sinon l'étape est ignorée (la CI reste verte tant que le serveur
|
||||
# SonarQube n'est pas branché).
|
||||
if: ${{ env.SONAR_TOKEN != '' }}
|
||||
uses: sonarsource/sonarqube-scan-action@v5
|
||||
# Le scanner lit sonar-project.properties et utilise SONAR_HOST_URL /
|
||||
# SONAR_TOKEN exposés au niveau du job.
|
||||
@@ -0,0 +1,70 @@
|
||||
name: CD - Déploiement continu
|
||||
|
||||
# Déploiement continu : après un push sur master, une fois la CI passée,
|
||||
# on se connecte en SSH au serveur de production et on met à jour l'application
|
||||
# (git pull, rebuild des conteneurs, migrations, mise en cache).
|
||||
#
|
||||
# Secrets à définir dans Gitea (Settings > Actions > Secrets du dépôt) :
|
||||
# - SSH_HOST : IP ou domaine du serveur
|
||||
# - SSH_USER : utilisateur SSH
|
||||
# - SSH_PRIVATE_KEY : clé privée SSH autorisée sur le serveur
|
||||
# - SSH_PORT : port SSH (optionnel, 22 par défaut)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Déploiement sur le serveur via SSH
|
||||
uses: appleboy/ssh-action@v1.2.0
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
script: |
|
||||
set -e
|
||||
cd /home/sam/mon-app-laravel
|
||||
|
||||
DEPLOY_UID=$(id -u)
|
||||
|
||||
# L'utilisateur de déploiement doit posséder storage/ & bootstrap/cache
|
||||
# AVANT toute opération git (un déploiement précédent a pu les passer à
|
||||
# www-data, ce qui bloquerait git). chown via le conteneur (= root).
|
||||
docker exec -u 0 laravel_php chown -R "${DEPLOY_UID}:www-data" \
|
||||
/var/www/storage /var/www/bootstrap/cache
|
||||
|
||||
# Récupération propre de la dernière version : on ÉCRASE toute modif
|
||||
# locale parasite (le serveur est une copie de déploiement, aucune
|
||||
# édition manuelle n'est censée y vivre). Évite les abandons du type
|
||||
# "local changes would be overwritten by merge". Les fichiers non
|
||||
# suivis (cert.pem, .env…) ne sont PAS touchés par reset --hard.
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
|
||||
# Reconstruction et redémarrage des conteneurs
|
||||
docker compose up -d --build
|
||||
|
||||
# Migrations de la base de données (sans interaction)
|
||||
docker exec laravel_php php artisan migrate --force
|
||||
|
||||
# Mise en cache de la configuration et des routes (performances en prod)
|
||||
docker exec laravel_php php artisan config:cache
|
||||
docker exec laravel_php php artisan route:cache
|
||||
docker exec laravel_php php artisan view:cache
|
||||
|
||||
# Correction des permissions sur les dossiers écrits par Laravel.
|
||||
# Laravel (www-data) doit pouvoir écrire dans storage/ et
|
||||
# bootstrap/cache, MAIS l'utilisateur SSH doit rester propriétaire
|
||||
# pour que les futurs "git pull/reset" ne soient pas bloqués (sinon
|
||||
# les .gitignore suivis passent à www-data et git échoue).
|
||||
# => propriétaire = utilisateur de déploiement, groupe = www-data,
|
||||
# avec écriture de groupe + setgid (héritage du groupe www-data).
|
||||
docker exec -u 0 laravel_php sh -c "\
|
||||
chown -R ${DEPLOY_UID}:www-data /var/www/storage /var/www/bootstrap/cache && \
|
||||
chmod -R ug+rwX /var/www/storage /var/www/bootstrap/cache && \
|
||||
find /var/www/storage /var/www/bootstrap/cache -type d -exec chmod g+s {} +"
|
||||
@@ -1,27 +0,0 @@
|
||||
name: Deploy Laravel App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ] # Déclenche le déploiement lors d'un push sur la branche main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to Server via SSH
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.HOST }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
cd /home/sam/mon-app-laravel
|
||||
git pull origin main
|
||||
docker-compose up -d --build
|
||||
docker exec laravel_app php artisan migrate --force
|
||||
docker exec laravel_app php artisan config:cache
|
||||
docker exec laravel_app php artisan route:cache
|
||||
- name: Fix permissions
|
||||
run: |
|
||||
docker exec -u 0 laravel_php chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache /tmp
|
||||
docker exec laravel_php php artisan view:clear
|
||||
@@ -9,13 +9,17 @@
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.scannerwork
|
||||
/.vscode
|
||||
/coverage
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/public/downloads/*.apk
|
||||
/public/downloads/*.ipa
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
FROM php:8.2-fpm
|
||||
|
||||
# Installation des dépendances système
|
||||
# libicu-dev est requis pour l'extension PHP intl.
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git curl libpng-dev libonig-dev libxml2-dev zip unzip
|
||||
git curl libpng-dev libonig-dev libxml2-dev libicu-dev zip unzip
|
||||
|
||||
# Nettoyage du cache
|
||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Installation des extensions PHP pour Laravel et MySQL
|
||||
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
|
||||
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd intl
|
||||
|
||||
# Récupération de Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
@@ -1,59 +1,70 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
# (RE)Sources Relationnelles
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
Plateforme de partage de ressources relationnelles à destination des citoyens
|
||||
(simulation d'un projet du Ministère des Solidarités et de la Santé).
|
||||
Projet collaboratif — **CDA / CESI — Groupe 1**.
|
||||
|
||||
## About Laravel
|
||||
Application web développée avec **Laravel 12** (architecture MVC), conteneurisée
|
||||
avec **Docker** et déployée en continu via **Gitea Actions**.
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
- Application : https://app.sam-coffre.duckdns.org
|
||||
- Dépôt Git : https://gitea.sam-coffre.duckdns.org/
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
## Stack technique
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
| Élément | Technologie |
|
||||
|---------|-------------|
|
||||
| Framework | Laravel 12 (PHP 8.2) |
|
||||
| Base de données | MySQL / MariaDB (SQLite en test) |
|
||||
| Serveur web | Nginx (reverse proxy + TLS) |
|
||||
| Conteneurisation | Docker / Docker Compose |
|
||||
| Gestion de version | Git + Gitea |
|
||||
| CI/CD | Gitea Actions |
|
||||
| Qualité de code | Laravel Pint |
|
||||
| Tests | PHPUnit |
|
||||
|
||||
## Learning Laravel
|
||||
## Installation en local
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
||||
```bash
|
||||
git clone https://gitea.sam-coffre.duckdns.org/<groupe>/ressources-relationnel.git
|
||||
cd ressources-relationnel
|
||||
cp .env.example .env
|
||||
composer install
|
||||
php artisan key:generate
|
||||
php artisan migrate --seed
|
||||
npm install && npm run build
|
||||
php artisan serve
|
||||
```
|
||||
|
||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
## Développement
|
||||
|
||||
## Laravel Sponsors
|
||||
```bash
|
||||
# Lancer les tests automatisés
|
||||
php artisan test
|
||||
|
||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||
# Vérifier le style de code
|
||||
vendor/bin/pint --test
|
||||
|
||||
### Premium Partners
|
||||
# Corriger automatiquement le style de code
|
||||
vendor/bin/pint
|
||||
```
|
||||
|
||||
- **[Vehikl](https://vehikl.com)**
|
||||
- **[Tighten Co.](https://tighten.co)**
|
||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||
- **[64 Robots](https://64robots.com)**
|
||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||
- **[Redberry](https://redberry.international/laravel-development)**
|
||||
- **[Active Logic](https://activelogic.com)**
|
||||
## Déploiement
|
||||
|
||||
## Contributing
|
||||
Le déploiement est **automatisé** : un `push` sur la branche `master` déclenche
|
||||
la CI (qualité + tests) puis, si tout est vert, le déploiement en production.
|
||||
Voir [`docs/02-plan-de-deploiement.md`](docs/02-plan-de-deploiement.md).
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
## Documentation (rendu bloc INFCDAAL3)
|
||||
|
||||
## Code of Conduct
|
||||
La documentation technique « Déployer et sécuriser » est dans le dossier
|
||||
[`docs/`](docs/README.md) :
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
- [Plan de sécurisation](docs/01-plan-de-securisation.md)
|
||||
- [Plan de déploiement](docs/02-plan-de-deploiement.md)
|
||||
- [Gestion des versions](docs/03-gestion-des-versions.md)
|
||||
- [Gestion des évolutions](docs/04-gestion-des-evolutions.md)
|
||||
|
||||
## Security Vulnerabilities
|
||||
## Licence
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
Projet pédagogique — usage limité au cadre de la formation.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
class DownloadController extends Controller
|
||||
{
|
||||
// Page publique de téléchargement des applications mobiles
|
||||
public function index()
|
||||
{
|
||||
return view('telecharger', [
|
||||
'android' => $this->resoudre('android'),
|
||||
'ios' => $this->resoudre('ios'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine, pour une plateforme, le lien de téléchargement et sa disponibilité.
|
||||
*
|
||||
* @return array{version: string, url: string|null, disponible: bool}
|
||||
*/
|
||||
private function resoudre(string $plateforme): array
|
||||
{
|
||||
$config = config("mobile.{$plateforme}");
|
||||
if (! is_array($config)) {
|
||||
$config = [];
|
||||
}
|
||||
|
||||
$version = is_string($config['version'] ?? null) ? $config['version'] : '1.0.0';
|
||||
$url = (is_string($config['url'] ?? null) && $config['url'] !== '') ? $config['url'] : null;
|
||||
$file = is_string($config['file'] ?? null) ? $config['file'] : null;
|
||||
|
||||
// À défaut d'URL explicite, on propose le fichier précompilé s'il existe.
|
||||
if ($url === null && $file !== null && file_exists(public_path($file))) {
|
||||
$url = asset($file);
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'url' => $url,
|
||||
'disponible' => $url !== null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Utilisateur;
|
||||
use App\Models\Role;
|
||||
use App\Models\Utilisateur;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -36,6 +36,7 @@ class LoginController extends Controller
|
||||
|
||||
if (Auth::attempt($loginCredentials)) {
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->route('home')->with('success', 'Connexion réussie!');
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class ProfileController extends Controller
|
||||
{
|
||||
/** @var Utilisateur $user */
|
||||
$user = Auth::user();
|
||||
|
||||
return view('profile.settings', compact('user'));
|
||||
}
|
||||
|
||||
@@ -24,8 +25,8 @@ class ProfileController extends Controller
|
||||
$user = Auth::user();
|
||||
|
||||
$request->validate([
|
||||
'Pseudo' => ['required', 'string', 'max:100', 'unique:utilisateurs,Pseudo,' . $user->ID_Utilisateur . ',ID_Utilisateur'],
|
||||
'Email' => ['required', 'string', 'email', 'max:255', 'unique:utilisateurs,Email,' . $user->ID_Utilisateur . ',ID_Utilisateur'],
|
||||
'Pseudo' => ['required', 'string', 'max:100', 'unique:utilisateurs,Pseudo,'.$user->ID_Utilisateur.',ID_Utilisateur'],
|
||||
'Email' => ['required', 'string', 'email', 'max:255', 'unique:utilisateurs,Email,'.$user->ID_Utilisateur.',ID_Utilisateur'],
|
||||
'MotDePasse_Actuel' => ['nullable', 'string'],
|
||||
'MotDePasse_Nouveau' => ['nullable', 'string', 'min:8', 'confirmed'],
|
||||
], [
|
||||
@@ -41,7 +42,7 @@ class ProfileController extends Controller
|
||||
|
||||
// Vérifier le mot de passe actuel si on veut le modifier
|
||||
if ($request->filled('MotDePasse_Nouveau')) {
|
||||
if (!Hash::check($request->MotDePasse_Actuel, $user->MotDePasse)) {
|
||||
if (! Hash::check($request->MotDePasse_Actuel, $user->MotDePasse)) {
|
||||
return back()->withErrors([
|
||||
'MotDePasse_Actuel' => 'Le mot de passe actuel est incorrect.',
|
||||
])->onlyInput('Email', 'Pseudo');
|
||||
@@ -56,4 +57,34 @@ class ProfileController extends Controller
|
||||
|
||||
return back()->with('success', 'Votre profil a été mis à jour avec succès!');
|
||||
}
|
||||
|
||||
// Supprimer / anonymiser son compte (droit à l'effacement RGPD)
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
/** @var Utilisateur $user */
|
||||
$user = Auth::user();
|
||||
|
||||
// Vérification du mot de passe pour confirmer l'action sensible
|
||||
$request->validate([
|
||||
'MotDePasse_Confirmation' => ['required', 'string'],
|
||||
], [
|
||||
'MotDePasse_Confirmation.required' => 'Veuillez saisir votre mot de passe pour confirmer.',
|
||||
]);
|
||||
|
||||
if (! Hash::check($request->MotDePasse_Confirmation, $user->MotDePasse)) {
|
||||
return back()->withErrors([
|
||||
'MotDePasse_Confirmation' => 'Mot de passe incorrect. Suppression annulée.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Anonymisation des données personnelles, puis déconnexion sécurisée
|
||||
$user->anonymiser();
|
||||
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('home')
|
||||
->with('success', 'Votre compte a été supprimé et vos données personnelles anonymisées.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Suggestion;
|
||||
use App\Models\Utilisateur;
|
||||
use App\Services\GiteaIssueService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SuggestionController extends Controller
|
||||
{
|
||||
// Afficher le formulaire de suggestion
|
||||
public function create()
|
||||
{
|
||||
return view('suggestions.create');
|
||||
}
|
||||
|
||||
// Traiter la suggestion et créer un ticket dans Gitea
|
||||
public function store(Request $request, GiteaIssueService $gitea)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'type' => ['required', Rule::in(['Modification', 'Ajout', 'Correction'])],
|
||||
'titre' => ['required', 'string', 'min:5', 'max:120'],
|
||||
'description' => ['required', 'string', 'min:10', 'max:5000'],
|
||||
], [
|
||||
'type.required' => 'Veuillez choisir un type de demande.',
|
||||
'type.in' => 'Type de demande invalide.',
|
||||
'titre.required' => 'Le titre est requis.',
|
||||
'titre.min' => 'Le titre doit contenir au moins 5 caractères.',
|
||||
'titre.max' => 'Le titre ne peut pas dépasser 120 caractères.',
|
||||
'description.required' => 'La description est requise.',
|
||||
'description.min' => 'Merci de détailler un peu plus votre suggestion (10 caractères minimum).',
|
||||
]);
|
||||
|
||||
/** @var Utilisateur $user */
|
||||
$user = Auth::user();
|
||||
$auteur = $user->Pseudo ?? 'Utilisateur';
|
||||
|
||||
$title = "[{$data['type']}] {$data['titre']}";
|
||||
|
||||
$body = "**Type de demande :** {$data['type']}\n"
|
||||
."**Proposé par :** {$auteur} (via le site (RE)Sources Relationnelles)\n\n"
|
||||
."### Description\n"
|
||||
.$data['description']."\n\n"
|
||||
."---\n"
|
||||
.'_Ticket créé automatiquement depuis le formulaire de suggestions du site._';
|
||||
|
||||
$numero = $gitea->createIssue($title, $body);
|
||||
|
||||
if ($numero === null) {
|
||||
return back()->withInput()->withErrors([
|
||||
'gitea' => "L'envoi de votre suggestion a échoué. Merci de réessayer plus tard.",
|
||||
]);
|
||||
}
|
||||
|
||||
// La suggestion est conservée en base, liée au ticket, pour le suivi.
|
||||
Suggestion::create([
|
||||
'ID_Utilisateur' => $user->ID_Utilisateur,
|
||||
'Type' => $data['type'],
|
||||
'Titre' => $data['titre'],
|
||||
'Description' => $data['description'],
|
||||
'GiteaIssueNumber' => $numero,
|
||||
]);
|
||||
|
||||
return redirect()->route('suggestions.index')
|
||||
->with('success', 'Merci ! Votre suggestion a bien été transmise à l\'équipe. Vous pouvez suivre sa prise en compte ici.');
|
||||
}
|
||||
|
||||
// Liste des suggestions de l'utilisateur, avec l'état de chaque ticket
|
||||
public function index(GiteaIssueService $gitea)
|
||||
{
|
||||
$suggestions = Suggestion::where('ID_Utilisateur', Auth::user()->ID_Utilisateur)
|
||||
->orderByDesc('DateCreation')
|
||||
->get();
|
||||
|
||||
// État des tickets (ouvert/fermé, nb de réponses), mis en cache 60 s
|
||||
// pour ne pas appeler l'API Gitea à chaque affichage de la page.
|
||||
$etats = [];
|
||||
|
||||
foreach ($suggestions as $suggestion) {
|
||||
if ($suggestion->GiteaIssueNumber === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$etat = Cache::remember(
|
||||
'gitea.issue.'.$suggestion->GiteaIssueNumber,
|
||||
60,
|
||||
fn () => $gitea->getIssue($suggestion->GiteaIssueNumber) ?? false,
|
||||
);
|
||||
|
||||
$etats[$suggestion->ID_Suggestion] = $etat ?: null;
|
||||
}
|
||||
|
||||
return view('suggestions.index', compact('suggestions', 'etats'));
|
||||
}
|
||||
|
||||
// Détail d'une suggestion : fil de discussion du ticket Gitea
|
||||
public function show(Suggestion $suggestion, GiteaIssueService $gitea)
|
||||
{
|
||||
abort_unless($suggestion->ID_Utilisateur === Auth::user()->ID_Utilisateur, 403);
|
||||
|
||||
$issue = null;
|
||||
$commentaires = [];
|
||||
|
||||
if ($suggestion->GiteaIssueNumber !== null) {
|
||||
$issue = $gitea->getIssue($suggestion->GiteaIssueNumber);
|
||||
$commentaires = $gitea->getComments($suggestion->GiteaIssueNumber);
|
||||
|
||||
// Tout est lu : le badge « nouvelle réponse » disparaît de la liste.
|
||||
$suggestion->CommentairesVus = count($commentaires);
|
||||
$suggestion->save();
|
||||
}
|
||||
|
||||
return view('suggestions.show', compact('suggestion', 'issue', 'commentaires'));
|
||||
}
|
||||
|
||||
// Répondre sur le ticket : le commentaire est publié sur Gitea
|
||||
public function repondre(Request $request, Suggestion $suggestion, GiteaIssueService $gitea)
|
||||
{
|
||||
abort_unless($suggestion->ID_Utilisateur === Auth::user()->ID_Utilisateur, 403);
|
||||
abort_if($suggestion->GiteaIssueNumber === null, 404);
|
||||
|
||||
$data = $request->validate([
|
||||
'message' => ['required', 'string', 'min:2', 'max:2000'],
|
||||
], [
|
||||
'message.required' => 'Votre message est vide.',
|
||||
'message.min' => 'Votre message est trop court.',
|
||||
'message.max' => 'Votre message ne peut pas dépasser 2000 caractères.',
|
||||
]);
|
||||
|
||||
/** @var Utilisateur $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$corps = '**'.($user->Pseudo ?? 'Utilisateur')."** (via le site) :\n\n".$data['message'];
|
||||
|
||||
if (! $gitea->addComment($suggestion->GiteaIssueNumber, $corps)) {
|
||||
return back()->withInput()->withErrors([
|
||||
'gitea' => "L'envoi de votre réponse a échoué. Merci de réessayer plus tard.",
|
||||
]);
|
||||
}
|
||||
|
||||
// L'état du ticket a changé : on invalide le cache de la liste.
|
||||
Cache::forget('gitea.issue.'.$suggestion->GiteaIssueNumber);
|
||||
|
||||
return redirect()->route('suggestions.show', $suggestion)
|
||||
->with('success', 'Votre réponse a été publiée sur le ticket.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ForceHttps
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
// En production, forcer HTTPS même si on arrive en HTTP.
|
||||
// On exclut les environnements local et testing (pas de TLS, et
|
||||
// la redirection casserait la suite de tests automatisés / CI).
|
||||
if (! app()->environment(['local', 'testing']) && ! $request->isSecure()) {
|
||||
return redirect(
|
||||
str_replace('http://', 'https://', $request->url()),
|
||||
308
|
||||
)->send();
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,43 @@ class SecurityHeaders
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
// Ajouter les headers de sécurité
|
||||
// --- En-têtes de sécurité HTTP (défense en profondeur) ---
|
||||
|
||||
// Force le navigateur à n'utiliser que HTTPS pendant 1 an (protection contre le SSL stripping).
|
||||
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains', true);
|
||||
|
||||
// Empêche le navigateur de "deviner" le type MIME d'une ressource (protection contre le MIME sniffing).
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff', true);
|
||||
|
||||
// Interdit l'affichage du site dans une iframe externe (protection contre le clickjacking).
|
||||
$response->headers->set('X-Frame-Options', 'SAMEORIGIN', true);
|
||||
|
||||
// Ancienne protection XSS des navigateurs (conservée pour les navigateurs anciens).
|
||||
$response->headers->set('X-XSS-Protection', '1; mode=block', true);
|
||||
|
||||
// Limite les informations de provenance envoyées aux sites tiers.
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin', true);
|
||||
|
||||
// Désactive des API sensibles du navigateur non utilisées par l'application (RGPD / vie privée).
|
||||
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=()', true);
|
||||
|
||||
// Content-Security-Policy : réduit fortement le risque d'injection de scripts (XSS).
|
||||
// Politique volontairement compatible avec Laravel/Vite (styles et scripts inline autorisés).
|
||||
// Pour durcir davantage, remplacer 'unsafe-inline' par un système de nonce.
|
||||
// NB : l'application charge Tailwind via CDN (cdn.tailwindcss.com) et
|
||||
// les avatars via ui-avatars.com : ces sources externes sont donc autorisées.
|
||||
$response->headers->set('Content-Security-Policy', implode('; ', [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com",
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com",
|
||||
"img-src 'self' data: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"frame-ancestors 'self'",
|
||||
]), true);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ use Illuminate\Database\Eloquent\Model;
|
||||
class Activite extends Model
|
||||
{
|
||||
protected $primaryKey = 'ID_Instance';
|
||||
|
||||
protected $table = 'activites';
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
|
||||
@@ -7,8 +7,11 @@ use Illuminate\Database\Eloquent\Model;
|
||||
class ActiviteMessage extends Model
|
||||
{
|
||||
protected $primaryKey = 'ID_Message';
|
||||
|
||||
protected $table = 'activite_messages';
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
|
||||
@@ -7,7 +7,9 @@ use Illuminate\Database\Eloquent\Model;
|
||||
class Categorie extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $primaryKey = 'ID_Categorie';
|
||||
|
||||
protected $table = 'categories';
|
||||
|
||||
protected $fillable = [
|
||||
|
||||
@@ -7,7 +7,9 @@ use Illuminate\Database\Eloquent\Model;
|
||||
class Commentaire extends Model
|
||||
{
|
||||
protected $primaryKey = 'ID_Commentaire';
|
||||
|
||||
protected $table = 'commentaires';
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
protected $fillable = [
|
||||
@@ -42,4 +44,4 @@ class Commentaire extends Model
|
||||
{
|
||||
return $this->belongsTo(Commentaire::class, 'ID_Parent_Commentaire', 'ID_Commentaire');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Apprend à un modèle Eloquent à gérer une clé primaire COMPOSITE
|
||||
* (plusieurs colonnes), ce qu'Eloquent ne sait pas faire nativement.
|
||||
*
|
||||
* Eloquent suppose que `$primaryKey` est une seule colonne (chaîne). On garde
|
||||
* donc `$primaryKey` sous forme de chaîne (contrat respecté) et on déclare les
|
||||
* colonnes de la clé composite via compositeKeyColumns(). Les requêtes de
|
||||
* mise à jour / suppression / sélection par clé filtrent alors sur CHAQUE
|
||||
* colonne de la clé.
|
||||
*
|
||||
* Le modèle qui l'utilise doit :
|
||||
* - déclarer `public $incrementing = false;`
|
||||
* - implémenter compositeKeyColumns() en listant les colonnes de la clé.
|
||||
*/
|
||||
trait HasCompositePrimaryKey
|
||||
{
|
||||
/**
|
||||
* Colonnes qui composent la clé primaire composite.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
abstract protected function compositeKeyColumns(): array;
|
||||
|
||||
/**
|
||||
* Valeur de la clé sous forme de tableau « colonne => valeur ».
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getKey()
|
||||
{
|
||||
$key = [];
|
||||
|
||||
foreach ($this->compositeKeyColumns() as $column) {
|
||||
$key[$column] = $this->getAttribute($column);
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cible chaque colonne de la clé composite lors d'un save / update.
|
||||
*
|
||||
* @param Builder<static> $query
|
||||
* @return Builder<static>
|
||||
*/
|
||||
protected function setKeysForSaveQuery($query)
|
||||
{
|
||||
foreach ($this->compositeKeyColumns() as $column) {
|
||||
$query->where($column, '=', $this->getKeyForSaveQuery($column));
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valeur d'une colonne de la clé pour un save (valeur d'origine si dispo).
|
||||
*
|
||||
* @param string|null $column
|
||||
* @return mixed
|
||||
*/
|
||||
protected function getKeyForSaveQuery($column = null)
|
||||
{
|
||||
$column ??= $this->getKeyName();
|
||||
|
||||
return $this->original[$column] ?? $this->getAttribute($column);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cible chaque colonne de la clé composite lors d'une sélection par clé.
|
||||
*
|
||||
* @param Builder<static> $query
|
||||
* @return Builder<static>
|
||||
*/
|
||||
protected function setKeysForSelectQuery($query)
|
||||
{
|
||||
foreach ($this->compositeKeyColumns() as $column) {
|
||||
$query->where($column, '=', $this->getKeyForSelectQuery($column));
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valeur d'une colonne de la clé pour une sélection.
|
||||
*
|
||||
* @param string|null $column
|
||||
* @return mixed
|
||||
*/
|
||||
protected function getKeyForSelectQuery($column = null)
|
||||
{
|
||||
$column ??= $this->getKeyName();
|
||||
|
||||
return $this->original[$column] ?? $this->getAttribute($column);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ use Illuminate\Database\Eloquent\Model;
|
||||
class Ressource extends Model
|
||||
{
|
||||
protected $primaryKey = 'ID_Ressource';
|
||||
|
||||
protected $table = 'ressources';
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
protected $fillable = [
|
||||
@@ -59,4 +61,4 @@ class Ressource extends Model
|
||||
{
|
||||
return $this->hasMany(Utilisateur_Progression::class, 'ID_Ressource', 'ID_Ressource');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ use Illuminate\Database\Eloquent\Model;
|
||||
class Role extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $primaryKey = 'ID_Role';
|
||||
|
||||
protected $table = 'roles';
|
||||
|
||||
protected $fillable = [
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $ID_Suggestion
|
||||
* @property int $ID_Utilisateur
|
||||
* @property string $Type
|
||||
* @property string $Titre
|
||||
* @property string $Description
|
||||
* @property int|null $GiteaIssueNumber
|
||||
* @property int $CommentairesVus
|
||||
* @property Carbon $DateCreation
|
||||
*/
|
||||
class Suggestion extends Model
|
||||
{
|
||||
protected $table = 'suggestions';
|
||||
|
||||
protected $primaryKey = 'ID_Suggestion';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'ID_Utilisateur',
|
||||
'Type',
|
||||
'Titre',
|
||||
'Description',
|
||||
'GiteaIssueNumber',
|
||||
'CommentairesVus',
|
||||
'DateCreation',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'DateCreation' => 'datetime',
|
||||
];
|
||||
|
||||
public function utilisateur(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Utilisateur::class, 'ID_Utilisateur', 'ID_Utilisateur');
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ use Illuminate\Database\Eloquent\Model;
|
||||
class TypeRessource extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $primaryKey = 'ID_Type_Ressource';
|
||||
|
||||
protected $table = 'type_ressources';
|
||||
|
||||
protected $fillable = [
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $ID_Utilisateur
|
||||
@@ -12,7 +13,7 @@ use Illuminate\Notifications\Notifiable;
|
||||
* @property string $Pseudo
|
||||
* @property string $Statut
|
||||
* @property int $ID_Role
|
||||
* @property \Illuminate\Support\Carbon $DateInscription
|
||||
* @property Carbon $DateInscription
|
||||
*/
|
||||
class Utilisateur extends Authenticatable
|
||||
{
|
||||
@@ -20,10 +21,15 @@ class Utilisateur extends Authenticatable
|
||||
|
||||
// Nom de la table
|
||||
protected $table = 'utilisateurs';
|
||||
|
||||
|
||||
// La table utilise DateInscription et non created_at/updated_at
|
||||
public $timestamps = false;
|
||||
|
||||
// Clé primaire personnalisée
|
||||
protected $primaryKey = 'ID_Utilisateur';
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
protected $keyType = 'int';
|
||||
|
||||
// Colonnes modifiables (Mass Assignment)
|
||||
@@ -88,4 +94,21 @@ class Utilisateur extends Authenticatable
|
||||
{
|
||||
return $this->hasMany(Utilisateur_Progression::class, 'ID_Utilisateur', 'ID_Utilisateur');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymise le compte (droit à l'effacement RGPD).
|
||||
*
|
||||
* Les données personnelles identifiantes (e-mail, pseudo, mot de passe)
|
||||
* sont remplacées par des valeurs neutres et non réversibles, tandis que
|
||||
* les contributions liées (ressources, commentaires) restent en base sous
|
||||
* une forme anonyme afin de préserver l'intégrité référentielle.
|
||||
*/
|
||||
public function anonymiser(): void
|
||||
{
|
||||
$this->Email = 'anonyme_'.$this->ID_Utilisateur.'@anonymise.local';
|
||||
$this->Pseudo = 'Utilisateur supprimé';
|
||||
$this->MotDePasse = bin2hex(random_bytes(32)); // mot de passe non utilisable
|
||||
$this->Statut = 'Inactif'; // compte désactivé (valeur de l'enum Statut)
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,33 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasCompositePrimaryKey;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Utilisateur_Progression extends Model
|
||||
{
|
||||
use HasCompositePrimaryKey;
|
||||
|
||||
protected $table = 'utilisateur_progressions';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $primaryKey = ['ID_Utilisateur', 'ID_Ressource'];
|
||||
// Clé primaire composite (ID_Utilisateur + ID_Ressource), gérée par le
|
||||
// trait via compositeKeyColumns(). Eloquent impose une chaîne pour
|
||||
// $primaryKey : on y met la 1re colonne pour rester conforme au contrat.
|
||||
protected $primaryKey = 'ID_Utilisateur';
|
||||
|
||||
/**
|
||||
* Colonnes composant la clé primaire composite.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function compositeKeyColumns(): array
|
||||
{
|
||||
return ['ID_Utilisateur', 'ID_Ressource'];
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'ID_Utilisateur',
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -20,7 +20,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// Forcer HTTPS
|
||||
URL::forceScheme('https');
|
||||
if (! app()->environment('local')) {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
// ForceHttps middleware se charge de forcer HTTPS
|
||||
// TrustProxies middleware reconnaît les headers du proxy
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class GiteaIssueService
|
||||
{
|
||||
/**
|
||||
* Crée un ticket (issue) dans le dépôt Gitea configuré.
|
||||
*
|
||||
* @return int|null le numéro du ticket créé, ou null en cas d'échec.
|
||||
*/
|
||||
public function createIssue(string $title, string $body): ?int
|
||||
{
|
||||
$client = $this->client();
|
||||
|
||||
if ($client === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $client->post($this->repoPath().'/issues', [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Appel API Gitea impossible : '.$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Création du ticket Gitea échouée', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $response->json('number');
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'état d'un ticket (état ouvert/fermé, nombre de commentaires).
|
||||
*
|
||||
* @return array{state: string, comments: int, title: string}|null
|
||||
*/
|
||||
public function getIssue(int $number): ?array
|
||||
{
|
||||
$client = $this->client();
|
||||
|
||||
if ($client === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $client->get($this->repoPath().'/issues/'.$number);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Lecture du ticket Gitea impossible : '.$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'state' => (string) $response->json('state'),
|
||||
'comments' => (int) $response->json('comments'),
|
||||
'title' => (string) $response->json('title'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les commentaires (réponses) d'un ticket.
|
||||
*
|
||||
* @return list<array{auteur: string, corps: string, date: string}>
|
||||
*/
|
||||
public function getComments(int $number): array
|
||||
{
|
||||
$client = $this->client();
|
||||
|
||||
if ($client === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $client->get($this->repoPath().'/issues/'.$number.'/comments');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Lecture des commentaires Gitea impossible : '.$e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$commentaires = [];
|
||||
|
||||
foreach ($response->json() ?? [] as $commentaire) {
|
||||
$commentaires[] = [
|
||||
'auteur' => (string) ($commentaire['user']['username'] ?? $commentaire['user']['login'] ?? 'inconnu'),
|
||||
'corps' => (string) ($commentaire['body'] ?? ''),
|
||||
'date' => (string) ($commentaire['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $commentaires;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un commentaire (réponse de l'utilisateur) sur un ticket.
|
||||
*/
|
||||
public function addComment(int $number, string $body): bool
|
||||
{
|
||||
$client = $this->client();
|
||||
|
||||
if ($client === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $client->post($this->repoPath().'/issues/'.$number.'/comments', [
|
||||
'body' => $body,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Ajout du commentaire Gitea impossible : '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Ajout du commentaire Gitea échoué', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client HTTP configuré, ou null si la configuration Gitea est incomplète.
|
||||
*/
|
||||
private function client(): ?PendingRequest
|
||||
{
|
||||
$url = rtrim((string) config('services.gitea.url'), '/');
|
||||
$token = (string) config('services.gitea.token');
|
||||
|
||||
if ($url === '' || $this->repo() === '' || $token === '') {
|
||||
Log::warning('Gitea non configuré (URL / token / dépôt manquant).');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Gitea attend l'en-tête "Authorization: token <TOKEN>".
|
||||
return Http::baseUrl($url)
|
||||
->withToken($token, 'token')
|
||||
->acceptJson()
|
||||
->timeout(10);
|
||||
}
|
||||
|
||||
private function repo(): string
|
||||
{
|
||||
return trim((string) config('services.gitea.repo'), '/');
|
||||
}
|
||||
|
||||
private function repoPath(): string
|
||||
{
|
||||
return '/api/v1/repos/'.$this->repo();
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -1,9 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\ForceHttps;
|
||||
use App\Http\Middleware\SecurityHeaders;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use App\Http\Middleware\SecurityHeaders;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -12,6 +13,12 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
// L'application tourne derrière nginx-proxy-manager : on fait confiance
|
||||
// au reverse proxy pour lire X-Forwarded-Proto/Host (sinon Laravel croit
|
||||
// être en HTTP et génère de mauvaises URL / redirections).
|
||||
$middleware->trustProxies(at: '*');
|
||||
|
||||
$middleware->append(ForceHttps::class);
|
||||
$middleware->append(SecurityHeaders::class);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
||||
+7
-1
@@ -12,11 +12,13 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"larastan/larastan": "^3.10",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.24",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpro/grumphp": "^2.22",
|
||||
"phpunit/phpunit": "^11.5.50"
|
||||
},
|
||||
"autoload": {
|
||||
@@ -76,9 +78,13 @@
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"platform": {
|
||||
"php": "8.2.32"
|
||||
},
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
"php-http/discovery": true,
|
||||
"phpro/grumphp": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
|
||||
Generated
+2760
-327
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -52,7 +52,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
'url' => env('APP_URL', 'https://app.sam-coffre.duckdns.org/login'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -41,7 +41,7 @@ return [
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||
'url' => rtrim(env('APP_URL', 'https://app.sam-coffre.duckdns.org/login'), '/').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ return [
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'https://app.sam-coffre.duckdns.org/login'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Applications mobiles (téléchargement)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Pour chaque plateforme, la page de téléchargement utilise en priorité
|
||||
| l'URL fournie (lien de store OU URL directe). À défaut, elle propose le
|
||||
| fichier précompilé déposé dans public/downloads/. Si aucun des deux n'est
|
||||
| disponible, le bouton affiche « bientôt disponible ».
|
||||
|
|
||||
*/
|
||||
|
||||
'android' => [
|
||||
'version' => env('MOBILE_ANDROID_VERSION', '1.0.0'),
|
||||
'url' => env('MOBILE_ANDROID_URL'),
|
||||
'file' => 'downloads/ressources-relationnelles.apk',
|
||||
],
|
||||
|
||||
'ios' => [
|
||||
'version' => env('MOBILE_IOS_VERSION', '1.0.0'),
|
||||
'url' => env('MOBILE_IOS_URL'),
|
||||
'file' => 'downloads/ressources-relationnelles.ipa',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -35,4 +35,12 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
// Gitea : création automatique de tickets (issues) depuis le formulaire
|
||||
// de suggestions du site. Le token doit avoir le scope "write:issue".
|
||||
'gitea' => [
|
||||
'url' => env('GITEA_URL', 'https://gitea.sam-coffre.duckdns.org'),
|
||||
'token' => env('GITEA_TOKEN'),
|
||||
'repo' => env('GITEA_REPO', 'Groupe1/Ressources-relationnel'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('roles', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('roles');
|
||||
}
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('utilisateurs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('Email')->unique();
|
||||
$table->string('MotDePasse');
|
||||
$table->string('Pseudo')->nullable();
|
||||
$table->unsignedBigInteger('ID_Role')->nullable();
|
||||
$table->string('Statut')->default('Actif');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('utilisateurs');
|
||||
}
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('activites', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('activites');
|
||||
}
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('categories');
|
||||
}
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('commentaires', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('commentaires');
|
||||
}
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ressources', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ressources');
|
||||
}
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('type_ressources', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('type_ressources');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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
|
||||
{
|
||||
// Suivi des suggestions : chaque suggestion est liée au ticket Gitea
|
||||
// créé automatiquement, pour permettre à l'utilisateur de suivre les
|
||||
// réponses de l'équipe depuis son interface web.
|
||||
Schema::create('suggestions', function (Blueprint $table) {
|
||||
$table->id('ID_Suggestion');
|
||||
$table->foreignId('ID_Utilisateur')->constrained('utilisateurs', 'ID_Utilisateur')->onDelete('cascade');
|
||||
$table->string('Type', 50);
|
||||
$table->string('Titre', 150);
|
||||
$table->text('Description');
|
||||
$table->unsignedInteger('GiteaIssueNumber')->nullable();
|
||||
// Nombre de commentaires déjà consultés (pour le badge « nouvelle réponse »)
|
||||
$table->unsignedInteger('CommentairesVus')->default(0);
|
||||
$table->timestamp('DateCreation')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('suggestions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
# Serveur SonarQube (auto-hébergé) pour l'analyse de qualité du code.
|
||||
#
|
||||
# Démarrage : docker compose -f docker-compose.sonar.yml up -d
|
||||
# Interface : http://localhost:9000 (identifiants par défaut : admin / admin)
|
||||
# Arrêt : docker compose -f docker-compose.sonar.yml down
|
||||
#
|
||||
# ⚠️ Prérequis hôte (Elasticsearch embarqué dans SonarQube) :
|
||||
# Linux : sudo sysctl -w vm.max_map_count=262144
|
||||
# (à rendre permanent dans /etc/sysctl.conf)
|
||||
# Prévoir ~2-3 Go de RAM disponibles pour le conteneur.
|
||||
|
||||
services:
|
||||
sonarqube:
|
||||
image: sonarqube:community
|
||||
container_name: sonarqube
|
||||
depends_on:
|
||||
- sonar-db
|
||||
ports:
|
||||
- "9000:9000"
|
||||
environment:
|
||||
SONAR_JDBC_URL: jdbc:postgresql://sonar-db:5432/sonar
|
||||
SONAR_JDBC_USERNAME: sonar
|
||||
SONAR_JDBC_PASSWORD: sonar
|
||||
volumes:
|
||||
- sonarqube_data:/opt/sonarqube/data
|
||||
- sonarqube_extensions:/opt/sonarqube/extensions
|
||||
- sonarqube_logs:/opt/sonarqube/logs
|
||||
|
||||
sonar-db:
|
||||
image: postgres:15
|
||||
container_name: sonar-db
|
||||
environment:
|
||||
POSTGRES_USER: sonar
|
||||
POSTGRES_PASSWORD: sonar
|
||||
POSTGRES_DB: sonar
|
||||
volumes:
|
||||
- sonar_postgresql:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
sonarqube_data:
|
||||
sonarqube_extensions:
|
||||
sonarqube_logs:
|
||||
sonar_postgresql:
|
||||
+1
-1
@@ -14,7 +14,7 @@ services:
|
||||
container_name: laravel_web
|
||||
restart: always
|
||||
ports:
|
||||
- "8000:80"
|
||||
- "8005:80"
|
||||
- "8443:443" # HTTPS port
|
||||
volumes:
|
||||
- .:/var/www
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# 01 — Plan de sécurisation
|
||||
|
||||
Projet **(RE)Sources Relationnelles** — Bloc INFCDAAL3
|
||||
|
||||
> Contexte : plateforme ministérielle manipulant des **données personnelles**
|
||||
> (comptes citoyens, ressources privées, échanges modérés). La sécurité et la
|
||||
> conformité **RGPD** sont donc des exigences fortes.
|
||||
|
||||
## 1. Identification des risques de sécurité
|
||||
|
||||
Analyse inspirée du **Top 10 OWASP** et adaptée au projet.
|
||||
|
||||
| Risque | Exemple sur le projet | Gravité | Traitement mis en place |
|
||||
|--------|-----------------------|---------|-------------------------|
|
||||
| Injection SQL | Recherche / filtres de ressources | Élevée | ORM Eloquent (requêtes préparées), validation des entrées |
|
||||
| XSS (injection de scripts) | Commentaires, ressources créées par les citoyens | Élevée | Échappement Blade `{{ }}`, en-tête **CSP** |
|
||||
| CSRF | Formulaires (login, création de ressource) | Élevée | Jeton CSRF Laravel automatique sur les formulaires |
|
||||
| Vol de session | Cookies de session | Élevée | Cookies `HttpOnly` + `Secure`, HTTPS forcé, `session.regenerate()` à la connexion |
|
||||
| Mots de passe faibles / en clair | Comptes citoyens | Élevée | Hachage **bcrypt** (`hashed` cast), min. 8 caractères |
|
||||
| Contrôle d'accès défaillant | Accès Back Office / pages profil | Élevée | Middleware `auth`, rôles (Citoyen / Modérateur / Admin / Super-Admin) |
|
||||
| Man-in-the-middle | Transport des données | Élevée | **TLS/HTTPS** obligatoire + **HSTS** |
|
||||
| Exposition d'informations | Messages d'erreur, debug | Moyenne | `APP_DEBUG=false` et `APP_ENV=production` en prod |
|
||||
| Clickjacking | Intégration en iframe | Moyenne | En-tête `X-Frame-Options: SAMEORIGIN` |
|
||||
| Dépendances vulnérables | Paquets Composer / npm | Moyenne | `composer audit` / mises à jour régulières |
|
||||
|
||||
## 2. Mesures de prévention des risques
|
||||
|
||||
### 2.1 Sécurité applicative (implémentée dans le code)
|
||||
|
||||
- **Authentification** : hachage bcrypt des mots de passe, régénération de
|
||||
session à la connexion, invalidation à la déconnexion
|
||||
(`app/Http/Controllers/LoginController.php`).
|
||||
- **Contrôle d'accès** : les routes sensibles sont regroupées derrière le
|
||||
middleware `auth` (`routes/web.php`), avec un modèle de **rôles**
|
||||
(`app/Models/Role.php`).
|
||||
- **Validation des entrées** : toutes les données reçues sont validées
|
||||
(règles `required`, `email`, `unique`, `min:8`, `confirmed`…).
|
||||
- **Protection CSRF** : activée par défaut sur tous les formulaires POST/PUT.
|
||||
- **En-têtes de sécurité HTTP** (`app/Http/Middleware/SecurityHeaders.php`) :
|
||||
- `Strict-Transport-Security` (HSTS) — impose HTTPS,
|
||||
- `Content-Security-Policy` (CSP) — limite les sources de scripts (anti-XSS),
|
||||
- `X-Content-Type-Options: nosniff`,
|
||||
- `X-Frame-Options: SAMEORIGIN` (anti-clickjacking),
|
||||
- `Referrer-Policy` et `Permissions-Policy`.
|
||||
- **Forçage HTTPS** (`app/Http/Middleware/ForceHttps.php`) : redirection
|
||||
automatique de HTTP vers HTTPS hors environnement local/test.
|
||||
|
||||
### 2.2 Sécurité du transport et de l'infrastructure
|
||||
|
||||
- **TLS/HTTPS** terminé au niveau du reverse proxy (`nginx.conf`) : le port 80
|
||||
redirige en 301 vers le port 443.
|
||||
- Nom de domaine du service : `https://app.sam-coffre.duckdns.org`.
|
||||
- Séparation des rôles conteneurs : PHP-FPM (`laravel_php`) et serveur web
|
||||
(`laravel_web`) isolés (`docker-compose.yml`).
|
||||
|
||||
## 3. Chiffrement des données
|
||||
|
||||
| Donnée | État au repos | En transit |
|
||||
|--------|---------------|-----------|
|
||||
| Mots de passe | Hachage **bcrypt** (non réversible) | HTTPS |
|
||||
| Sessions / cookies | Chiffrés via `APP_KEY` (AES-256), `SESSION_ENCRYPT=true` en prod | HTTPS + cookie `Secure`/`HttpOnly` |
|
||||
| Données personnelles sensibles | Chiffrables via les *casts* `encrypted` d'Eloquent | HTTPS |
|
||||
| Communications client ↔ serveur | — | **TLS 1.2+** obligatoire |
|
||||
|
||||
> Laravel chiffre en **AES-256-CBC** à partir de la clé `APP_KEY`. Cette clé est
|
||||
> stockée hors dépôt (`.env` est dans `.gitignore`) et ne doit jamais être versionnée.
|
||||
|
||||
## 4. Conformité RGPD
|
||||
|
||||
- **Minimisation** : seules les données nécessaires sont collectées (pseudo, e-mail).
|
||||
- **Base légale et information** : pages légales présentes
|
||||
(`resources/views/legal/mentions.blade.php`,
|
||||
`resources/views/legal/politique.blade.php`).
|
||||
- **Droit d'accès / rectification** : espace profil
|
||||
(`/profile/settings`, `ProfileController`).
|
||||
- **Droit à l'effacement / anonymisation** : implémenté. Depuis son espace
|
||||
profil, l'utilisateur peut supprimer son compte (confirmation par mot de passe) ;
|
||||
ses données personnelles (e-mail, pseudo, mot de passe) sont alors anonymisées
|
||||
de façon irréversible via `Utilisateur::anonymiser()`
|
||||
(`app/Http/Controllers/ProfileController@destroy`), tout en préservant
|
||||
l'intégrité référentielle des contributions.
|
||||
- **Sécurité par défaut** (privacy by design) : chiffrement, HTTPS, contrôle d'accès.
|
||||
- **Sous-traitance et hébergement** : hébergement maîtrisé, journalisation des accès.
|
||||
|
||||
## 5. Bonnes pratiques de développement
|
||||
|
||||
- **Style de code homogène** : **Laravel Pint** (PSR-12), vérifié en CI
|
||||
(`vendor/bin/pint --test`).
|
||||
- **Commentaires** : le code de sécurité (middlewares) est documenté en français.
|
||||
- **Tests automatisés** : `tests/Feature/SecuriteTest.php` vérifie les en-têtes
|
||||
de sécurité et le contrôle d'accès aux pages protégées.
|
||||
- **Séparation des environnements** : configuration via `.env` (jamais de secret en dur).
|
||||
- **Architecture MVC** : contrôleurs / modèles / vues séparés.
|
||||
|
||||
## 6. Synthèse — traçabilité risque → mesure
|
||||
|
||||
Chaque risque du §1 est couvert par au moins une mesure du §2/§3/§4, et les
|
||||
mesures critiques (en-têtes, contrôle d'accès) sont **vérifiées automatiquement**
|
||||
par la CI à chaque évolution du code.
|
||||
@@ -0,0 +1,109 @@
|
||||
# 02 — Plan de déploiement
|
||||
|
||||
Projet **(RE)Sources Relationnelles** — Bloc INFCDAAL3
|
||||
|
||||
## 1. Stratégie de déploiement
|
||||
|
||||
Le déploiement repose sur trois piliers :
|
||||
|
||||
1. **Conteneurisation** (Docker) pour un environnement reproductible.
|
||||
2. **Gestion de version** (Gitea) comme source unique de vérité.
|
||||
3. **Intégration et déploiement continus** (Gitea Actions) pour automatiser
|
||||
qualité, tests et mise en production.
|
||||
|
||||
Approche retenue : à chaque `push` sur `master`, la CI valide le code
|
||||
(qualité + tests) ; si tout est vert, le déploiement se déclenche automatiquement
|
||||
sur le serveur.
|
||||
|
||||
## 2. Environnements
|
||||
|
||||
| Environnement | Rôle | Hébergement |
|
||||
|---------------|------|-------------|
|
||||
| **Local (dev)** | Développement sur le poste | Docker / `php artisan serve` |
|
||||
| **Test (CI)** | Exécution automatisée des tests | Runner Gitea Actions (base SQLite en mémoire) |
|
||||
| **Pré-production (QA)** *(recommandé)* | Recette avant mise en ligne | Conteneurs Docker, base dédiée |
|
||||
| **Production** | Service ouvert aux citoyens | Serveur `app.sam-coffre.duckdns.org` |
|
||||
|
||||
> La configuration de chaque environnement est portée par un fichier `.env`
|
||||
> distinct (jamais versionné). Seul `.env.example` est présent dans le dépôt.
|
||||
|
||||
## 3. Architecture de déploiement
|
||||
|
||||
```
|
||||
Internet (HTTPS)
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ Nginx (conteneur web) │ :443 TLS / redirection 80→443
|
||||
│ laravel_web │
|
||||
└─────────────┬─────────────┘
|
||||
│ FastCGI :9000
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ PHP-FPM (conteneur app) │ Laravel 12
|
||||
│ laravel_php │
|
||||
└─────────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
Base de données MySQL
|
||||
```
|
||||
|
||||
Fichiers concernés : `Dockerfile` (image PHP), `docker-compose.yml`
|
||||
(orchestration), `nginx.conf` (reverse proxy / TLS).
|
||||
|
||||
## 4. Pipeline d'intégration et de déploiement continus (CI/CD)
|
||||
|
||||
### 4.1 Intégration continue — `.gitea/workflows/ci.yml`
|
||||
|
||||
Déclenchée sur chaque `push` et chaque `pull_request` vers `master` :
|
||||
|
||||
1. Récupération du code.
|
||||
2. Installation de PHP 8.2 et des extensions.
|
||||
3. Installation des dépendances Composer (avec cache).
|
||||
4. **Vérification du style de code** : `vendor/bin/pint --test`.
|
||||
5. **Exécution des tests** : `php artisan test` (PHPUnit).
|
||||
|
||||
### 4.2 Déploiement continu — `.gitea/workflows/deploy.yml`
|
||||
|
||||
Déclenché sur `push` vers `master`, il se connecte en **SSH** au serveur et exécute :
|
||||
|
||||
1. `git pull origin master` — récupération de la nouvelle version.
|
||||
2. `docker compose up -d --build` — reconstruction des conteneurs.
|
||||
3. `php artisan migrate --force` — application des migrations de base.
|
||||
4. `config:cache`, `route:cache`, `view:cache` — optimisations de production.
|
||||
5. Correction des permissions (`storage`, `bootstrap/cache`).
|
||||
|
||||
**Secrets Gitea requis** (Settings → Actions → Secrets) : `SSH_HOST`,
|
||||
`SSH_USER`, `SSH_PRIVATE_KEY`, `SSH_PORT`.
|
||||
|
||||
> Prérequis : un **runner Gitea Actions** doit être enregistré sur l'instance
|
||||
> (`https://gitea.sam-coffre.duckdns.org/`) et les Actions activées pour le dépôt.
|
||||
|
||||
## 5. Plan de déploiement détaillé (étapes, ressources, responsables)
|
||||
|
||||
| # | Étape | Ressource / Outil | Responsable | Automatisé ? |
|
||||
|---|-------|-------------------|-------------|--------------|
|
||||
| 1 | Développement + commit | Git / Gitea | Développeur | Non |
|
||||
| 2 | Push sur `master` | Gitea | Développeur | Non |
|
||||
| 3 | Contrôle qualité (Pint) | Gitea Actions | Runner CI | ✅ Oui |
|
||||
| 4 | Tests automatisés (PHPUnit) | Gitea Actions | Runner CI | ✅ Oui |
|
||||
| 5 | Connexion au serveur | SSH | Runner CD | ✅ Oui |
|
||||
| 6 | Récupération du code | Git | Serveur | ✅ Oui |
|
||||
| 7 | Build des conteneurs | Docker Compose | Serveur | ✅ Oui |
|
||||
| 8 | Migrations BDD | Artisan | Serveur | ✅ Oui |
|
||||
| 9 | Mise en cache config/routes/vues | Artisan | Serveur | ✅ Oui |
|
||||
| 10 | Vérification post-déploiement | Endpoint `/up` (health check) | Équipe | Semi |
|
||||
|
||||
## 6. Retour arrière (rollback)
|
||||
|
||||
En cas de problème après un déploiement :
|
||||
|
||||
- **Code** : `git revert` ou retour à un tag précédent, puis nouveau déploiement.
|
||||
- **Base de données** : `php artisan migrate:rollback` (migrations réversibles).
|
||||
- **Conteneurs** : redéploiement de l'image précédente via `docker compose`.
|
||||
|
||||
## 7. Supervision
|
||||
|
||||
- **Health check** : endpoint `/up` exposé par Laravel (voir `bootstrap/app.php`).
|
||||
- **Journaux** : `storage/logs/laravel.log` + logs des conteneurs Docker.
|
||||
- **Historique des déploiements** : onglet *Actions* de Gitea.
|
||||
@@ -0,0 +1,61 @@
|
||||
# 03 — Outil de gestion des versions
|
||||
|
||||
Projet **(RE)Sources Relationnelles** — Bloc INFCDAAL3
|
||||
|
||||
## 1. Outil choisi : Git + Gitea (auto-hébergé)
|
||||
|
||||
Le versionnement du code repose sur **Git**, hébergé sur une instance
|
||||
**Gitea** auto-hébergée par l'équipe :
|
||||
|
||||
- Instance : `https://gitea.sam-coffre.duckdns.org/`
|
||||
- Motivations du choix :
|
||||
- **Maîtrise des données** : hébergement sur notre propre serveur (cohérent
|
||||
avec un contexte ministériel / RGPD, pas de dépendance à un tiers).
|
||||
- **Léger et open source** : Gitea est simple à administrer et gratuit.
|
||||
- **Fonctionnalités complètes** : dépôts Git, *pull requests*, *issues*,
|
||||
*releases*, et **Gitea Actions** (CI/CD) intégrés.
|
||||
|
||||
## 2. Organisation du dépôt
|
||||
|
||||
- **Branche principale** : `master` (branche de référence, protégée, déployée).
|
||||
- **Branches de fonctionnalité** *(recommandé)* : une branche par fonctionnalité
|
||||
(`feature/…`), fusionnée via *pull request* après relecture.
|
||||
- Fichiers de configuration Git présents : `.gitignore`, `.gitattributes`,
|
||||
`.editorconfig` (cohérence entre développeurs).
|
||||
|
||||
## 3. Convention de nommage des commits
|
||||
|
||||
Convention recommandée (type *Conventional Commits*) :
|
||||
|
||||
```
|
||||
<type>: <description courte>
|
||||
|
||||
Types : feat, fix, docs, style, refactor, test, chore
|
||||
Exemples :
|
||||
feat: ajout de la création de ressources
|
||||
fix: correction de la redirection HTTPS
|
||||
docs: rédaction du plan de sécurisation
|
||||
```
|
||||
|
||||
## 4. Flux de travail (workflow) proposé
|
||||
|
||||
1. Créer une branche depuis `master` : `git checkout -b feature/ma-fonctionnalite`.
|
||||
2. Développer + commits atomiques.
|
||||
3. Pousser la branche : `git push origin feature/ma-fonctionnalite`.
|
||||
4. Ouvrir une **Pull Request** sur Gitea.
|
||||
5. La **CI** (Pint + tests) s'exécute automatiquement sur la PR.
|
||||
6. Relecture par un pair, puis fusion dans `master`.
|
||||
7. La fusion déclenche le **déploiement automatique** en production.
|
||||
|
||||
## 5. Gestion des livraisons (releases)
|
||||
|
||||
- Utilisation des **tags Git** pour marquer les versions : `v1.0.0`, `v1.1.0`…
|
||||
- Convention **SemVer** : `MAJEUR.MINEUR.CORRECTIF`.
|
||||
- Les *Releases* Gitea documentent chaque version livrée (notes de version).
|
||||
|
||||
## 6. Sécurité du dépôt
|
||||
|
||||
- Aucun secret versionné : `.env`, clés et certificats sont exclus via
|
||||
`.gitignore`.
|
||||
- Accès au dépôt restreint aux membres de l'équipe (comptes Gitea).
|
||||
- Historique complet et traçable (auteur, date, message de chaque modification).
|
||||
@@ -0,0 +1,63 @@
|
||||
# 04 — Outil de gestion des demandes d'évolutions et des incidents
|
||||
|
||||
Projet **(RE)Sources Relationnelles** — Bloc INFCDAAL3
|
||||
|
||||
## 1. Outil choisi : les *Issues* de Gitea
|
||||
|
||||
La gestion des évolutions et des incidents s'appuie sur le module **Issues** de
|
||||
Gitea (déjà utilisé pour le code), ce qui évite d'ajouter un outil externe et
|
||||
centralise le suivi au même endroit que le code et les déploiements.
|
||||
|
||||
- Instance : `https://gitea.sam-coffre.duckdns.org/`
|
||||
|
||||
## 2. Typologie des demandes (labels)
|
||||
|
||||
Chaque demande est catégorisée par un **label** :
|
||||
|
||||
| Label | Usage |
|
||||
|-------|-------|
|
||||
| `bug` | Incident / anomalie en production |
|
||||
| `evolution` | Demande d'évolution fonctionnelle |
|
||||
| `securite` | Faille ou correctif de sécurité (priorité haute) |
|
||||
| `documentation` | Mise à jour de documentation |
|
||||
| `priorite:haute` / `priorite:basse` | Niveau de priorité |
|
||||
|
||||
## 3. Cycle de vie d'une demande
|
||||
|
||||
```
|
||||
Ouverte → À traiter → En cours → En test (QA) → Résolue / Fermée
|
||||
```
|
||||
|
||||
- Une *issue* est **liée à une branche et à une Pull Request** ; la fusion de la
|
||||
PR ferme automatiquement l'issue (mot-clé `Closes #12` dans le commit).
|
||||
- Le tableau **Projects (Kanban)** de Gitea permet de visualiser l'avancement
|
||||
(colonnes : À faire / En cours / Terminé).
|
||||
- Les **Milestones** regroupent les demandes par version cible (ex. `v1.1`).
|
||||
|
||||
## 4. Processus de gestion des incidents (maintenance corrective)
|
||||
|
||||
1. **Détection** : signalement (utilisateur, supervision, logs).
|
||||
2. **Qualification** : création d'une issue `bug`, évaluation de la gravité.
|
||||
3. **Priorisation** : affectation d'un label de priorité et d'un responsable.
|
||||
4. **Correction** : branche `fix/…`, développement, tests.
|
||||
5. **Validation** : CI verte + recette en QA.
|
||||
6. **Déploiement** : fusion sur `master` → mise en production automatique.
|
||||
7. **Clôture** : fermeture de l'issue, note de version si nécessaire.
|
||||
|
||||
## 5. Processus de gestion des évolutions (maintenance évolutive)
|
||||
|
||||
1. **Recueil du besoin** : issue `evolution` décrivant le besoin et la valeur.
|
||||
2. **Analyse et estimation** : faisabilité, impact, charge.
|
||||
3. **Planification** : affectation à un *milestone* / une version.
|
||||
4. **Réalisation** : branche `feature/…`, développement + tests.
|
||||
5. **Revue et intégration** : Pull Request relue, CI validée.
|
||||
6. **Livraison** : déploiement continu, communication de la nouvelle version.
|
||||
|
||||
## 6. Traçabilité
|
||||
|
||||
Grâce à Gitea, chaque évolution est traçable de bout en bout :
|
||||
|
||||
**Issue → Branche → Commits → Pull Request → CI → Déploiement → Release**
|
||||
|
||||
Cette chaîne assure la traçabilité complète exigée pour la maintenance d'une
|
||||
application manipulant des données sensibles.
|
||||
@@ -0,0 +1,148 @@
|
||||
# 05 — Mise en place de Gitea Actions (CI/CD)
|
||||
|
||||
Guide d'installation du moteur d'automatisation (CI/CD) pour le dépôt.
|
||||
Contexte : **Gitea en Docker / docker-compose**, accès **SSH** au serveur.
|
||||
|
||||
Gitea Actions nécessite deux choses :
|
||||
1. **Activer** la fonctionnalité Actions dans Gitea.
|
||||
2. Enregistrer au moins un **runner** (`act_runner`) qui exécute les workflows.
|
||||
|
||||
---
|
||||
|
||||
## Étape 1 — Activer Actions dans Gitea
|
||||
|
||||
Se connecter en SSH au serveur, puis se placer dans le dossier du `docker-compose.yml`
|
||||
**de Gitea** (⚠️ celui de Gitea, pas celui de l'application Laravel).
|
||||
|
||||
### Option A (recommandée) — via variable d'environnement
|
||||
|
||||
Dans le service Gitea du `docker-compose.yml`, ajouter la variable :
|
||||
|
||||
```yaml
|
||||
services:
|
||||
server: # nom du service Gitea (peut s'appeler "gitea")
|
||||
image: gitea/gitea:latest
|
||||
environment:
|
||||
- GITEA__actions__ENABLED=true
|
||||
# ... reste de la configuration inchangé
|
||||
```
|
||||
|
||||
Puis recharger Gitea :
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Option B — via le fichier app.ini
|
||||
|
||||
Éditer le fichier `app.ini` (dans le volume Gitea, souvent
|
||||
`./gitea/gitea/conf/app.ini`) et ajouter :
|
||||
|
||||
```ini
|
||||
[actions]
|
||||
ENABLED = true
|
||||
```
|
||||
|
||||
Puis redémarrer : `docker compose restart`.
|
||||
|
||||
### Vérification
|
||||
|
||||
Dans l'interface web du dépôt, un onglet **« Actions »** doit apparaître.
|
||||
|
||||
---
|
||||
|
||||
## Étape 2 — Obtenir un jeton d'enregistrement du runner
|
||||
|
||||
Deux niveaux possibles :
|
||||
|
||||
- **Niveau instance** (runner partagé par tous les dépôts) :
|
||||
`Site Administration → Actions → Runners → Create new Runner`.
|
||||
- **Niveau dépôt** (runner dédié au projet) :
|
||||
`Dépôt → Settings → Actions → Runners → Create new Runner`.
|
||||
|
||||
Copier le **jeton (registration token)** affiché.
|
||||
|
||||
---
|
||||
|
||||
## Étape 3 — Lancer un runner (act_runner) en Docker
|
||||
|
||||
Créer un dossier de travail sur le serveur, par exemple `~/gitea-runner`, avec un
|
||||
fichier `docker-compose.yml` :
|
||||
|
||||
```yaml
|
||||
services:
|
||||
runner:
|
||||
image: gitea/act_runner:latest
|
||||
container_name: gitea_runner
|
||||
restart: always
|
||||
environment:
|
||||
GITEA_INSTANCE_URL: "https://gitea.sam-coffre.duckdns.org"
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN: "COLLER_LE_JETON_ICI"
|
||||
GITEA_RUNNER_NAME: "runner-docker-1"
|
||||
# Images utilisées pour `runs-on: ubuntu-latest`
|
||||
GITEA_RUNNER_LABELS: "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
```
|
||||
|
||||
> Le montage de `/var/run/docker.sock` permet au runner de créer les conteneurs
|
||||
> qui exécutent chaque job. L'image `catthehacker/ubuntu:act-latest` fournit un
|
||||
> environnement compatible `ubuntu-latest` (Node, outils de base).
|
||||
|
||||
Démarrer le runner :
|
||||
|
||||
```bash
|
||||
cd ~/gitea-runner
|
||||
docker compose up -d
|
||||
docker compose logs -f runner # doit afficher "runner registered successfully"
|
||||
```
|
||||
|
||||
Le runner apparaît alors en vert dans `Settings → Actions → Runners` du dépôt.
|
||||
|
||||
---
|
||||
|
||||
## Étape 4 — Ajouter les secrets de déploiement
|
||||
|
||||
Dans `Dépôt → Settings → Actions → Secrets`, créer :
|
||||
|
||||
| Secret | Valeur |
|
||||
|--------|--------|
|
||||
| `SSH_HOST` | IP ou domaine du serveur de production |
|
||||
| `SSH_USER` | utilisateur SSH (ex. `sam`) |
|
||||
| `SSH_PRIVATE_KEY` | clé privée SSH autorisée sur le serveur |
|
||||
| `SSH_PORT` | port SSH (ex. `22`) |
|
||||
|
||||
> Générer une paire de clés dédiée au déploiement :
|
||||
> `ssh-keygen -t ed25519 -C "gitea-deploy" -f deploy_key`
|
||||
> puis ajouter `deploy_key.pub` dans `~/.ssh/authorized_keys` du serveur, et
|
||||
> coller le contenu de `deploy_key` (clé privée) dans le secret `SSH_PRIVATE_KEY`.
|
||||
|
||||
---
|
||||
|
||||
## Étape 5 — Déclencher et vérifier
|
||||
|
||||
Un simple push sur `master` déclenche les workflows :
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "test: déclenche la CI"
|
||||
git push
|
||||
```
|
||||
|
||||
Dans l'onglet **Actions** du dépôt :
|
||||
- le workflow **CI** (`ci.yml`) exécute Pint puis PHPUnit ;
|
||||
- le workflow **CD** (`deploy.yml`) déploie via SSH.
|
||||
|
||||
Une exécution en vert constitue une **preuve idéale à intégrer au dossier** (capture d'écran).
|
||||
|
||||
---
|
||||
|
||||
## Dépannage rapide
|
||||
|
||||
| Symptôme | Cause probable / solution |
|
||||
|----------|---------------------------|
|
||||
| Pas d'onglet Actions | Actions non activé (Étape 1) ou Gitea non redémarré |
|
||||
| « No runner available » | Runner non enregistré / hors ligne (Étape 3) |
|
||||
| `ubuntu-latest` introuvable | Label non défini → variable `GITEA_RUNNER_LABELS` |
|
||||
| `actions/checkout` échoue | Le serveur doit accéder à github.com (Gitea proxy les actions) |
|
||||
| Déploiement SSH refusé | Vérifier la clé dans `authorized_keys` et les secrets SSH_* |
|
||||
Binary file not shown.
@@ -0,0 +1,36 @@
|
||||
# Dossier de rendu — Bloc INFCDAAL3
|
||||
|
||||
**Déployer et sécuriser les applications informatiques**
|
||||
Projet collaboratif : **(RE)Sources Relationnelles**
|
||||
|
||||
Ce dossier regroupe les documents techniques attendus pour le bloc INFCDAAL3.
|
||||
Il sert de base au **document écrit de 15 à 20 pages** demandé dans les consignes.
|
||||
|
||||
## Sommaire
|
||||
|
||||
| # | Document | Répond au critère de la grille |
|
||||
|---|----------|--------------------------------|
|
||||
| 01 | [Plan de sécurisation](01-plan-de-securisation.md) | Risques de sécurité, RGPD, chiffrement, prévention des risques |
|
||||
| 02 | [Plan de déploiement](02-plan-de-deploiement.md) | Environnement de déploiement, automatisation, intégration continue, étapes et ressources |
|
||||
| 03 | [Gestion des versions](03-gestion-des-versions.md) | Outil de gestion des versions (Gitea / Git) |
|
||||
| 04 | [Gestion des évolutions](04-gestion-des-evolutions.md) | Outil de gestion des demandes d'évolutions et des incidents |
|
||||
|
||||
## Correspondance avec la grille d'évaluation
|
||||
|
||||
- **Déploiement de l'application** → doc 02 + fichiers `Dockerfile`, `docker-compose.yml`, `nginx.conf`, `.gitea/workflows/`
|
||||
- **Maintenance** (gestion des versions et des évolutions) → docs 03 et 04
|
||||
- **Sécurité** (risques, RGPD, bonnes pratiques, plan de sécurisation) → doc 01 + code (`app/Http/Middleware/`, tests, Laravel Pint)
|
||||
- **Organisation / Prestation orale** → non technique (support de soutenance ~10 slides)
|
||||
|
||||
## Éléments techniques livrés dans le dépôt
|
||||
|
||||
| Élément | Emplacement | Objectif |
|
||||
|---------|-------------|----------|
|
||||
| Intégration continue (qualité + tests) | `.gitea/workflows/ci.yml` | Automatiser Pint + PHPUnit à chaque push |
|
||||
| Déploiement continu (SSH) | `.gitea/workflows/deploy.yml` | Automatiser la mise en production |
|
||||
| Conteneurisation | `Dockerfile`, `docker-compose.yml` | Environnement reproductible |
|
||||
| Serveur web / TLS | `nginx.conf` | Redirection HTTP→HTTPS, HTTPS |
|
||||
| En-têtes de sécurité | `app/Http/Middleware/SecurityHeaders.php` | HSTS, CSP, X-Frame-Options, etc. |
|
||||
| Forçage HTTPS | `app/Http/Middleware/ForceHttps.php` | Chiffrement du transport |
|
||||
| Tests de sécurité | `tests/Feature/SecuriteTest.php` | Vérification automatisée |
|
||||
| Qualité de code | `laravel/pint` (dev) | Standard PSR / style homogène |
|
||||
Binary file not shown.
+26
@@ -0,0 +1,26 @@
|
||||
# Configuration GrumPHP : vérifications automatiques avant chaque commit.
|
||||
# À chaque `git commit`, GrumPHP lance ces tâches ; si l'une échoue,
|
||||
# le commit est annulé. On évite ainsi de pousser du code non formaté
|
||||
# ou contenant des erreurs détectables statiquement (= CI qui reste verte).
|
||||
grumphp:
|
||||
# On laisse le temps aux analyses (Larastan peut être un peu long).
|
||||
process_timeout: 120
|
||||
|
||||
# On n'arrête pas au premier échec : on veut voir TOUS les problèmes
|
||||
# (Pint ET PHPStan) en une seule fois.
|
||||
stop_on_failure: false
|
||||
|
||||
tasks:
|
||||
# 1) Style de code (Laravel Pint) — mêmes règles qu'en CI.
|
||||
shell:
|
||||
scripts:
|
||||
- ['-c', 'vendor/bin/pint --test']
|
||||
|
||||
# 2) Analyse statique (Larastan / PHPStan) — détecte les vrais bugs.
|
||||
phpstan:
|
||||
configuration: phpstan.neon
|
||||
# false => on analyse les chemins définis dans phpstan.neon (app/)
|
||||
# plutôt que seulement les fichiers modifiés : analyse plus fiable.
|
||||
use_grumphp_paths: false
|
||||
# Larastan charge tout le framework : 128M par défaut ne suffit pas.
|
||||
memory_limit: '512M'
|
||||
+8
-7
@@ -1,14 +1,15 @@
|
||||
# Ce serveur nginx est DERRIÈRE nginx-proxy-manager (NPM), qui termine le TLS
|
||||
# public (certificat Let's Encrypt) et force déjà HTTP -> HTTPS pour le domaine.
|
||||
# Ici, on se contente donc de SERVIR l'application, sans redirection interne
|
||||
# (l'ancienne redirection vers https://$server_name renvoyait vers "localhost").
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name localhost;
|
||||
server_name app.sam-coffre.duckdns.org localhost _;
|
||||
|
||||
ssl_certificate /var/www/cert.pem;
|
||||
ssl_certificate_key /var/www/cert.key;
|
||||
|
||||
index index.php index.html;
|
||||
root /var/www/public;
|
||||
|
||||
@@ -26,4 +27,4 @@ server {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
gzip_static on;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
includes:
|
||||
- vendor/larastan/larastan/extension.neon
|
||||
|
||||
parameters:
|
||||
paths:
|
||||
- app/
|
||||
|
||||
# Niveau d'analyse statique (0 = souple, 9/max = très strict).
|
||||
# On démarre à 5 : bon compromis pour attraper les vrais bugs
|
||||
# (typage, null, méthodes inexistantes) sans bloquer sur des détails.
|
||||
level: 5
|
||||
@@ -5,6 +5,18 @@
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Reconnaître les headers du reverse proxy pour HTTPS
|
||||
<IfModule mod_setenvif.c>
|
||||
SetEnvIf X-Forwarded-Proto "https" HTTPS=on
|
||||
SetEnvIf X-Forwarded-Proto "^https$" SSL_PROTOCOL=TLSv1
|
||||
</IfModule>
|
||||
|
||||
# Forcer HTTPS en production
|
||||
RewriteCond %{ENV:HTTPS} !=on
|
||||
RewriteCond %{HTTP_HOST} !^localhost
|
||||
RewriteCond %{HTTP_HOST} !^127\.0\.0\.1
|
||||
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
# Handle Authorization Header
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Applications mobiles précompilées
|
||||
|
||||
Déposez ici les binaires servis par la page **/telecharger** :
|
||||
|
||||
- `ressources-relationnelles.apk` — application Android
|
||||
- `ressources-relationnelles.ipa` — application Apple (iOS)
|
||||
|
||||
La page détecte automatiquement la présence de ces fichiers.
|
||||
|
||||
## Alternative : liens de store
|
||||
|
||||
Plutôt que des fichiers locaux, vous pouvez renseigner des liens (Play Store,
|
||||
App Store, TestFlight…) dans le fichier `.env` du serveur :
|
||||
|
||||
```
|
||||
MOBILE_ANDROID_URL=https://play.google.com/store/apps/details?id=...
|
||||
MOBILE_IOS_URL=https://apps.apple.com/app/id...
|
||||
MOBILE_ANDROID_VERSION=1.0.0
|
||||
MOBILE_IOS_VERSION=1.0.0
|
||||
```
|
||||
|
||||
Si une URL est renseignée, elle est prioritaire sur le fichier local.
|
||||
Si ni URL ni fichier ne sont disponibles, le bouton affiche « Bientôt disponible ».
|
||||
|
||||
> Les binaires (`*.apk`, `*.ipa`) ne sont pas versionnés dans git (voir `.gitignore`).
|
||||
Binary file not shown.
@@ -23,7 +23,16 @@
|
||||
</div>
|
||||
|
||||
<nav class="flex items-center gap-2 whitespace-nowrap">
|
||||
<a href="{{ route('telecharger') }}" class="hidden lg:inline-flex items-center px-4 py-2.5 text-sm font-bold text-gray-700 rounded-lg hover:bg-gray-100 transition-smooth duration-200">
|
||||
📱 Télécharger l'app
|
||||
</a>
|
||||
@auth
|
||||
<a href="{{ route('suggestions.create') }}"
|
||||
title="Proposer une amélioration"
|
||||
aria-label="Proposer une amélioration"
|
||||
class="inline-flex items-center px-3 sm:px-4 py-2.5 text-sm font-bold text-niagara border-2 border-niagara rounded-lg hover:bg-niagara hover:text-white transition-smooth duration-200">
|
||||
💡<span class="hidden sm:inline"> Proposer une amélioration</span>
|
||||
</a>
|
||||
<div class="relative group">
|
||||
<button class="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-100 transition-smooth duration-200">
|
||||
<img
|
||||
@@ -36,7 +45,10 @@
|
||||
|
||||
<!-- Menu déroulant -->
|
||||
<div class="absolute right-0 mt-0 w-48 bg-white rounded-lg shadow-xl border border-gray-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
|
||||
<a href="{{ route('profile.settings') }}" class="block px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 border-b border-gray-200 rounded-t-lg">
|
||||
<a href="{{ route('suggestions.index') }}" class="block px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 border-b border-gray-200 rounded-t-lg">
|
||||
💬 Mes suggestions
|
||||
</a>
|
||||
<a href="{{ route('profile.settings') }}" class="block px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 border-b border-gray-200">
|
||||
⚙️ Paramètres du profil
|
||||
</a>
|
||||
<form action="{{ route('logout') }}" method="POST">
|
||||
|
||||
@@ -148,12 +148,39 @@
|
||||
<h2 class="text-2xl font-bold text-red-700 mb-4">⚠️ Zone de danger</h2>
|
||||
<p class="text-gray-600 mb-6">Les actions ci-dessous ne peuvent pas être annulées.</p>
|
||||
|
||||
<form action="{{ route('logout') }}" method="POST">
|
||||
<form action="{{ route('logout') }}" method="POST" class="mb-8">
|
||||
@csrf
|
||||
<button type="submit" class="px-8 py-3 bg-red-600 text-white font-bold rounded-lg hover:bg-red-700 transition-smooth duration-200">
|
||||
🚪 Se déconnecter
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="my-6">
|
||||
|
||||
<!-- Suppression du compte (droit à l'effacement RGPD) -->
|
||||
<h3 class="text-lg font-bold text-red-700 mb-2">Supprimer mon compte</h3>
|
||||
<p class="text-gray-600 text-sm mb-4">
|
||||
Conformément au RGPD, vous pouvez demander la suppression de votre compte.
|
||||
Vos données personnelles (e-mail, pseudo) seront définitivement anonymisées.
|
||||
Cette action est <strong>irréversible</strong>.
|
||||
</p>
|
||||
<form action="{{ route('profile.destroy') }}" method="POST"
|
||||
onsubmit="return confirm('Confirmer la suppression définitive de votre compte ?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<div class="mb-4 max-w-sm">
|
||||
<label for="MotDePasse_Confirmation" class="block text-sm font-bold mb-2 text-gray-700">Confirmez avec votre mot de passe</label>
|
||||
<input
|
||||
type="password"
|
||||
id="MotDePasse_Confirmation"
|
||||
name="MotDePasse_Confirmation"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 outline-none">
|
||||
</div>
|
||||
<button type="submit" class="px-8 py-3 bg-red-800 text-white font-bold rounded-lg hover:bg-red-900 transition-smooth duration-200">
|
||||
🗑️ Supprimer définitivement mon compte
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
@extends('app')
|
||||
|
||||
@section('title', 'Proposer une amélioration - (RE)Sources Relationnelles')
|
||||
|
||||
@section('content')
|
||||
<section class="container mx-auto px-4 py-8 sm:py-12">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- En-tête -->
|
||||
<div class="mb-6 sm:mb-8">
|
||||
<h1 class="text-2xl sm:text-4xl font-bold text-gray-900 mb-2">💡 Proposer une amélioration</h1>
|
||||
<p class="text-sm sm:text-base text-gray-600">
|
||||
Une idée, une correction, un ajout ? Votre suggestion est directement transmise à
|
||||
l'équipe sous forme de ticket. Merci de votre contribution !
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Message de succès -->
|
||||
@if (session('success'))
|
||||
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p class="text-green-700 font-semibold text-sm sm:text-base">✅ {{ session('success') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Erreurs -->
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
@foreach ($errors->all() as $error)
|
||||
<p class="text-red-700 text-sm">{{ $error }}</p>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-5 sm:p-8">
|
||||
<form action="{{ route('suggestions.store') }}" method="POST" class="space-y-5 sm:space-y-6">
|
||||
@csrf
|
||||
|
||||
<!-- Type de demande -->
|
||||
<div>
|
||||
<label for="type" class="block text-sm font-bold mb-2 text-gray-700">Type de demande</label>
|
||||
<select
|
||||
id="type"
|
||||
name="type"
|
||||
required
|
||||
class="w-full px-4 py-3 text-base border border-gray-300 rounded-lg focus:ring-2 focus:ring-niagara outline-none @error('type') border-red-500 @enderror">
|
||||
@php($typeChoisi = old('type'))
|
||||
<option value="Modification" @selected($typeChoisi === 'Modification')>✏️ Modification d'un contenu existant</option>
|
||||
<option value="Ajout" @selected($typeChoisi === 'Ajout')>➕ Ajout d'un contenu / fonctionnalité</option>
|
||||
<option value="Correction" @selected($typeChoisi === 'Correction')>🐛 Correction d'une erreur / d'un bug</option>
|
||||
</select>
|
||||
@error('type')
|
||||
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Titre -->
|
||||
<div>
|
||||
<label for="titre" class="block text-sm font-bold mb-2 text-gray-700">Titre</label>
|
||||
<input
|
||||
type="text"
|
||||
id="titre"
|
||||
name="titre"
|
||||
required
|
||||
minlength="5"
|
||||
maxlength="120"
|
||||
value="{{ old('titre') }}"
|
||||
placeholder="Résumez votre suggestion en une phrase"
|
||||
class="w-full px-4 py-3 text-base border border-gray-300 rounded-lg focus:ring-2 focus:ring-niagara outline-none @error('titre') border-red-500 @enderror">
|
||||
@error('titre')
|
||||
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<div class="flex items-baseline justify-between mb-2">
|
||||
<label for="description" class="block text-sm font-bold text-gray-700">Description détaillée</label>
|
||||
<span id="desc-count" class="text-xs text-gray-400 tabular-nums">0 / 5000</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
required
|
||||
minlength="10"
|
||||
maxlength="5000"
|
||||
rows="6"
|
||||
placeholder="Décrivez précisément votre proposition : quoi, où, pourquoi…"
|
||||
class="w-full px-4 py-3 text-base border border-gray-300 rounded-lg focus:ring-2 focus:ring-niagara outline-none @error('description') border-red-500 @enderror">{{ old('description') }}</textarea>
|
||||
@error('description')
|
||||
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Boutons : empilés pleine largeur sur mobile, en ligne sur desktop -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-2">
|
||||
<button type="submit" class="w-full sm:w-auto px-8 py-3 gradient-niagara text-white font-bold rounded-lg hover:shadow-lg transition-smooth duration-200 text-center">
|
||||
📨 Envoyer ma suggestion
|
||||
</button>
|
||||
<a href="{{ route('home') }}" class="w-full sm:w-auto px-8 py-3 bg-gray-200 text-gray-900 font-bold rounded-lg hover:bg-gray-300 transition-smooth duration-200 text-center">
|
||||
Annuler
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 mt-4">
|
||||
ℹ️ Votre suggestion sera examinée par l'équipe avant toute prise en compte. Merci de rester courtois et constructif.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(function () {
|
||||
const ta = document.getElementById('description');
|
||||
const compteur = document.getElementById('desc-count');
|
||||
if (!ta || !compteur) return;
|
||||
const maj = () => { compteur.textContent = ta.value.length + ' / 5000'; };
|
||||
ta.addEventListener('input', maj);
|
||||
maj();
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,75 @@
|
||||
@extends('app')
|
||||
|
||||
@section('title', 'Mes suggestions - (RE)Sources Relationnelles')
|
||||
|
||||
@section('content')
|
||||
<section class="container mx-auto px-4 py-12">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<!-- En-tête -->
|
||||
<div class="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-2">💬 Mes suggestions</h1>
|
||||
<p class="text-gray-600">
|
||||
Suivez ici la prise en compte de vos suggestions par l'équipe : état du ticket,
|
||||
réponses reçues, et poursuivez la discussion.
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ route('suggestions.create') }}" class="shrink-0 inline-flex items-center px-4 py-2.5 text-sm font-bold text-niagara border-2 border-niagara rounded-lg hover:bg-niagara hover:text-white transition-smooth duration-200">
|
||||
💡 Nouvelle suggestion
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Message de succès -->
|
||||
@if (session('success'))
|
||||
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p class="text-green-700 font-semibold">✅ {{ session('success') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@forelse ($suggestions as $suggestion)
|
||||
@php($etat = $etats[$suggestion->ID_Suggestion] ?? null)
|
||||
@php($nouvelles = $etat ? max(0, $etat['comments'] - $suggestion->CommentairesVus) : 0)
|
||||
<a href="{{ route('suggestions.show', $suggestion) }}"
|
||||
class="block bg-white rounded-xl border border-gray-200 p-6 mb-4 hover:border-niagara hover:shadow-md transition-smooth duration-200">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-700">{{ $suggestion->Type }}</span>
|
||||
@if ($etat === null)
|
||||
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-500">⚪ État indisponible</span>
|
||||
@elseif ($etat['state'] === 'closed')
|
||||
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-purple-100 text-purple-700">✔️ Traitée</span>
|
||||
@else
|
||||
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-green-100 text-green-700">🟢 En cours</span>
|
||||
@endif
|
||||
@if ($nouvelles > 0)
|
||||
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-candlelight text-gray-900">
|
||||
🔔 {{ $nouvelles }} {{ $nouvelles > 1 ? 'nouvelles réponses' : 'nouvelle réponse' }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="font-bold text-gray-900 truncate">{{ $suggestion->Titre }}</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
Envoyée le {{ $suggestion->DateCreation->format('d/m/Y à H\hi') }}
|
||||
@if ($suggestion->GiteaIssueNumber)
|
||||
· ticket n°{{ $suggestion->GiteaIssueNumber }}
|
||||
@endif
|
||||
@if ($etat)
|
||||
· {{ $etat['comments'] }} {{ $etat['comments'] > 1 ? 'réponses' : 'réponse' }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-gray-400 text-xl shrink-0">→</span>
|
||||
</div>
|
||||
</a>
|
||||
@empty
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-10 text-center">
|
||||
<p class="text-gray-600 mb-4">Vous n'avez pas encore envoyé de suggestion.</p>
|
||||
<a href="{{ route('suggestions.create') }}" class="inline-flex items-center px-6 py-3 font-bold bg-candlelight text-gray-900 rounded-lg shadow-md hover:shadow-lg transition-smooth duration-200">
|
||||
💡 Proposer une amélioration
|
||||
</a>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
@@ -0,0 +1,105 @@
|
||||
@extends('app')
|
||||
|
||||
@section('title', 'Suivi de ma suggestion - (RE)Sources Relationnelles')
|
||||
|
||||
@section('content')
|
||||
<section class="container mx-auto px-4 py-12">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<a href="{{ route('suggestions.index') }}" class="inline-block text-sm text-gray-500 hover:text-niagara mb-4">← Retour à mes suggestions</a>
|
||||
|
||||
<!-- En-tête -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-700">{{ $suggestion->Type }}</span>
|
||||
@if ($issue === null)
|
||||
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-500">⚪ État indisponible</span>
|
||||
@elseif ($issue['state'] === 'closed')
|
||||
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-purple-100 text-purple-700">✔️ Traitée</span>
|
||||
@else
|
||||
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-green-100 text-green-700">🟢 En cours de traitement</span>
|
||||
@endif
|
||||
@if ($suggestion->GiteaIssueNumber)
|
||||
<span class="text-xs text-gray-400">Ticket n°{{ $suggestion->GiteaIssueNumber }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ $suggestion->Titre }}</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Envoyée le {{ $suggestion->DateCreation->format('d/m/Y à H\hi') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
@if (session('success'))
|
||||
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p class="text-green-700 font-semibold">✅ {{ session('success') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
@foreach ($errors->all() as $error)
|
||||
<p class="text-red-700 text-sm">{{ $error }}</p>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Ma demande -->
|
||||
<div class="bg-white rounded-xl border-2 border-niagara p-6 mb-6">
|
||||
<p class="text-xs font-bold text-niagara uppercase mb-2">Votre demande</p>
|
||||
<p class="text-gray-800 whitespace-pre-line">{{ $suggestion->Description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Fil de discussion -->
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-4">
|
||||
Réponses {{ count($commentaires) > 0 ? '('.count($commentaires).')' : '' }}
|
||||
</h2>
|
||||
|
||||
@forelse ($commentaires as $commentaire)
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-5 mb-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<img
|
||||
src="https://ui-avatars.com/api/?name={{ urlencode($commentaire['auteur']) }}&background=06989e&color=fff&size=32"
|
||||
alt=""
|
||||
class="w-8 h-8 rounded-full"
|
||||
>
|
||||
<span class="font-bold text-gray-900 text-sm">{{ $commentaire['auteur'] }}</span>
|
||||
@if ($commentaire['date'])
|
||||
<span class="text-xs text-gray-400">· {{ \Illuminate\Support\Carbon::parse($commentaire['date'])->format('d/m/Y à H\hi') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="text-gray-800 text-sm whitespace-pre-line">{{ $commentaire['corps'] }}</p>
|
||||
</div>
|
||||
@empty
|
||||
<div class="bg-gray-50 rounded-xl border border-dashed border-gray-300 p-6 mb-3 text-center">
|
||||
<p class="text-gray-500 text-sm">
|
||||
Pas encore de réponse de l'équipe — vous serez notifié ici dès qu'une réponse sera publiée.
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
|
||||
<!-- Répondre -->
|
||||
@if ($suggestion->GiteaIssueNumber && ($issue['state'] ?? 'open') !== 'closed')
|
||||
<form action="{{ route('suggestions.repondre', $suggestion) }}" method="POST" class="bg-white rounded-xl border border-gray-200 p-6 mt-6">
|
||||
@csrf
|
||||
<label for="message" class="block text-sm font-bold mb-2 text-gray-700">Répondre sur le ticket</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="2000"
|
||||
rows="4"
|
||||
placeholder="Apportez une précision, répondez à l'équipe…"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-niagara outline-none @error('message') border-red-500 @enderror">{{ old('message') }}</textarea>
|
||||
@error('message')
|
||||
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
<button type="submit" class="mt-3 px-6 py-2.5 font-bold gradient-niagara text-white rounded-lg hover:shadow-lg transition-smooth duration-200">
|
||||
📨 Envoyer ma réponse
|
||||
</button>
|
||||
</form>
|
||||
@elseif (($issue['state'] ?? null) === 'closed')
|
||||
<p class="text-sm text-gray-500 mt-6 text-center">
|
||||
Cette suggestion a été traitée : le ticket est clôturé. Merci pour votre contribution !
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
@@ -0,0 +1,78 @@
|
||||
@extends('app')
|
||||
|
||||
@section('title', "Télécharger l'application - (RE)Sources Relationnelles")
|
||||
|
||||
@section('content')
|
||||
<section class="container mx-auto px-4 py-12">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<!-- En-tête -->
|
||||
<div class="text-center mb-10">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-3">📱 Télécharger l'application</h1>
|
||||
<p class="text-gray-600 max-w-xl mx-auto">
|
||||
Emportez (RE)Sources Relationnelles partout avec vous. Choisissez votre plateforme
|
||||
et installez l'application mobile.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
|
||||
{{-- Carte Android --}}
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-8 flex flex-col items-center text-center shadow-sm">
|
||||
<div class="w-16 h-16 rounded-2xl bg-green-50 flex items-center justify-center mb-4">
|
||||
<span class="text-4xl">🤖</span>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-1">Android</h2>
|
||||
<p class="text-sm text-gray-500 mb-6">Version {{ $android['version'] }}</p>
|
||||
|
||||
@if ($android['disponible'])
|
||||
<a href="{{ $android['url'] }}"
|
||||
@if (str_starts_with($android['url'], url('/'))) download @else target="_blank" rel="noopener" @endif
|
||||
class="w-full px-6 py-3 gradient-niagara text-white font-bold rounded-lg hover:shadow-lg transition-smooth duration-200">
|
||||
⬇️ Télécharger (.apk)
|
||||
</a>
|
||||
<p class="text-xs text-gray-400 mt-3">
|
||||
Sur Android, autorisez l'installation depuis « sources inconnues » si nécessaire.
|
||||
</p>
|
||||
@else
|
||||
<button type="button" disabled
|
||||
class="w-full px-6 py-3 bg-gray-200 text-gray-500 font-bold rounded-lg cursor-not-allowed">
|
||||
Bientôt disponible
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Carte iOS --}}
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-8 flex flex-col items-center text-center shadow-sm">
|
||||
<div class="w-16 h-16 rounded-2xl bg-gray-100 flex items-center justify-center mb-4">
|
||||
<span class="text-4xl"></span>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-1">Apple (iOS)</h2>
|
||||
<p class="text-sm text-gray-500 mb-6">Version {{ $ios['version'] }}</p>
|
||||
|
||||
@if ($ios['disponible'])
|
||||
<a href="{{ $ios['url'] }}"
|
||||
@if (str_starts_with($ios['url'], url('/'))) download @else target="_blank" rel="noopener" @endif
|
||||
class="w-full px-6 py-3 bg-gray-900 text-white font-bold rounded-lg hover:bg-black transition-smooth duration-200">
|
||||
⬇️ Télécharger
|
||||
</a>
|
||||
<p class="text-xs text-gray-400 mt-3">
|
||||
L'installation d'un fichier .ipa nécessite TestFlight ou un profil adapté.
|
||||
</p>
|
||||
@else
|
||||
<button type="button" disabled
|
||||
class="w-full px-6 py-3 bg-gray-200 text-gray-500 font-bold rounded-lg cursor-not-allowed">
|
||||
Bientôt disponible
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-10 text-center">
|
||||
<a href="{{ route('home') }}" class="text-sm font-semibold text-niagara hover:underline">
|
||||
← Retour à l'accueil
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
+38
-1
@@ -1,17 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\DownloadController;
|
||||
use App\Http\Controllers\LoginController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\SuggestionController;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
})->name('home');
|
||||
|
||||
Route::get('/form-csrf', function () {
|
||||
return view('form-csrf');
|
||||
});
|
||||
|
||||
Route::get('/debug-https', function (Request $request) {
|
||||
return response()->json([
|
||||
'isSecure' => $request->isSecure(),
|
||||
'url' => $request->url(),
|
||||
'scheme' => $request->getScheme(),
|
||||
'headers' => [
|
||||
'X-Forwarded-Proto' => $request->header('X-Forwarded-Proto'),
|
||||
'X-Forwarded-For' => $request->header('X-Forwarded-For'),
|
||||
'X-Forwarded-Host' => $request->header('X-Forwarded-Host'),
|
||||
'X-Forwarded-Port' => $request->header('X-Forwarded-Port'),
|
||||
'CF-Visitor' => $request->header('CF-Visitor'),
|
||||
],
|
||||
'SERVER_HTTPS' => $_SERVER['HTTPS'] ?? 'not set',
|
||||
'REQUEST_SCHEME' => $_SERVER['REQUEST_SCHEME'] ?? 'not set',
|
||||
]);
|
||||
});
|
||||
|
||||
Route::get('/resources', function () {
|
||||
return view('resources');
|
||||
})->name('resources.index');
|
||||
|
||||
// Téléchargement des applications mobiles (public)
|
||||
Route::get('/telecharger', [DownloadController::class, 'index'])->name('telecharger');
|
||||
|
||||
// Routes d'authentification
|
||||
Route::get('/login', [LoginController::class, 'login'])->name('login');
|
||||
Route::post('/login', [LoginController::class, 'store'])->name('login.store');
|
||||
@@ -25,4 +52,14 @@ Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/profile/settings', [ProfileController::class, 'settings'])->name('profile.settings');
|
||||
Route::put('/profile/update', [ProfileController::class, 'update'])->name('profile.update');
|
||||
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
||||
|
||||
// Suggestions des utilisateurs (créent un ticket dans Gitea) + suivi
|
||||
Route::get('/suggestions', [SuggestionController::class, 'index'])->name('suggestions.index');
|
||||
Route::get('/suggestions/nouveau', [SuggestionController::class, 'create'])->name('suggestions.create');
|
||||
Route::post('/suggestions', [SuggestionController::class, 'store'])->name('suggestions.store');
|
||||
Route::get('/suggestions/{suggestion}', [SuggestionController::class, 'show'])
|
||||
->whereNumber('suggestion')->name('suggestions.show');
|
||||
Route::post('/suggestions/{suggestion}/repondre', [SuggestionController::class, 'repondre'])
|
||||
->whereNumber('suggestion')->name('suggestions.repondre');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Configuration de l'analyse SonarQube.
|
||||
# L'URL du serveur (sonar.host.url) et le jeton (sonar.token) NE sont PAS ici :
|
||||
# ils sont fournis à la CI via les secrets SONAR_HOST_URL / SONAR_TOKEN.
|
||||
|
||||
sonar.projectKey=ressources-relationnelles
|
||||
sonar.projectName=Ressources Relationnelles
|
||||
|
||||
# Code source analysé et code de test.
|
||||
sonar.sources=app
|
||||
sonar.tests=tests
|
||||
sonar.sourceEncoding=UTF-8
|
||||
|
||||
# Rapports produits par PHPUnit (générés dans la CI, voir ci.yml).
|
||||
sonar.php.coverage.reportPaths=coverage/clover.xml
|
||||
sonar.php.tests.reportPath=coverage/junit.xml
|
||||
|
||||
# On n'analyse pas le code tiers, généré ou compilé.
|
||||
sonar.exclusions=vendor/**,storage/**,bootstrap/cache/**,public/build/**,node_modules/**
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\Utilisateur;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SecuriteTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Vérifie que les en-têtes de sécurité sont bien présents sur les réponses.
|
||||
*/
|
||||
public function test_les_entetes_de_securite_sont_presents(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertHeader('X-Content-Type-Options', 'nosniff');
|
||||
$response->assertHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
$response->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$response->assertHeader('Content-Security-Policy');
|
||||
$response->assertHeader('Permissions-Policy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que les pages protégées redirigent un visiteur non authentifié
|
||||
* vers la page de connexion (contrôle d'accès).
|
||||
*/
|
||||
public function test_les_pages_protegees_exigent_une_authentification(): void
|
||||
{
|
||||
$response = $this->get('/profile/settings');
|
||||
|
||||
$response->assertRedirect('/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie le droit à l'effacement RGPD : la suppression du compte
|
||||
* anonymise bien les données personnelles de l'utilisateur.
|
||||
*/
|
||||
public function test_la_suppression_de_compte_anonymise_les_donnees(): void
|
||||
{
|
||||
$role = Role::create(['Nom' => 'Utilisateur']);
|
||||
|
||||
$user = Utilisateur::create([
|
||||
'Pseudo' => 'JeanDupont',
|
||||
'Email' => 'jean.dupont@example.com',
|
||||
'MotDePasse' => Hash::make('motdepasse123'),
|
||||
'ID_Role' => $role->ID_Role,
|
||||
'Statut' => 'Actif',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->delete('/profile', [
|
||||
'MotDePasse_Confirmation' => 'motdepasse123',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/');
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame('Utilisateur supprimé', $user->Pseudo);
|
||||
$this->assertSame('Inactif', $user->Statut);
|
||||
$this->assertStringNotContainsString('jean.dupont', $user->Email);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\Suggestion;
|
||||
use App\Models\Utilisateur;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SuggestionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function utilisateur(string $pseudo = 'JeanTest', string $email = 'jean.test@example.com'): Utilisateur
|
||||
{
|
||||
$role = Role::firstOrCreate(['Nom' => 'Utilisateur']);
|
||||
|
||||
return Utilisateur::create([
|
||||
'Pseudo' => $pseudo,
|
||||
'Email' => $email,
|
||||
'MotDePasse' => Hash::make('motdepasse123'),
|
||||
'ID_Role' => $role->ID_Role,
|
||||
'Statut' => 'Actif',
|
||||
]);
|
||||
}
|
||||
|
||||
private function suggestion(Utilisateur $utilisateur, ?int $numero = 42): Suggestion
|
||||
{
|
||||
return Suggestion::create([
|
||||
'ID_Utilisateur' => $utilisateur->ID_Utilisateur,
|
||||
'Type' => 'Correction',
|
||||
'Titre' => 'Faute dans une ressource',
|
||||
'Description' => 'Il y a une faute à corriger sur la page des ressources.',
|
||||
'GiteaIssueNumber' => $numero,
|
||||
]);
|
||||
}
|
||||
|
||||
private function configurerGitea(): void
|
||||
{
|
||||
config([
|
||||
'services.gitea.url' => 'https://gitea.test',
|
||||
'services.gitea.token' => 'fake-token',
|
||||
'services.gitea.repo' => 'Groupe1/Ressources-relationnel',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_le_formulaire_de_suggestion_exige_une_authentification(): void
|
||||
{
|
||||
$this->get('/suggestions/nouveau')->assertRedirect('/login');
|
||||
$this->get('/suggestions')->assertRedirect('/login');
|
||||
}
|
||||
|
||||
public function test_une_suggestion_valide_cree_un_ticket_gitea_et_est_conservee(): void
|
||||
{
|
||||
$this->configurerGitea();
|
||||
Http::fake([
|
||||
'*/api/v1/repos/*/issues' => Http::response(['number' => 42], 201),
|
||||
]);
|
||||
|
||||
$utilisateur = $this->utilisateur();
|
||||
|
||||
$response = $this->actingAs($utilisateur)->post('/suggestions', [
|
||||
'type' => 'Correction',
|
||||
'titre' => 'Faute dans une ressource',
|
||||
'description' => 'Il y a une faute à corriger sur la page des ressources.',
|
||||
]);
|
||||
|
||||
// L'utilisateur arrive sur la page de suivi de ses suggestions
|
||||
$response->assertRedirect(route('suggestions.index'));
|
||||
$response->assertSessionHas('success');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/api/v1/repos/Groupe1/Ressources-relationnel/issues')
|
||||
&& $request['title'] === '[Correction] Faute dans une ressource'
|
||||
&& str_contains($request['body'], 'JeanTest');
|
||||
});
|
||||
|
||||
// La suggestion est liée au ticket pour permettre le suivi
|
||||
$this->assertDatabaseHas('suggestions', [
|
||||
'ID_Utilisateur' => $utilisateur->ID_Utilisateur,
|
||||
'Titre' => 'Faute dans une ressource',
|
||||
'GiteaIssueNumber' => 42,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_la_liste_affiche_l_etat_du_ticket_et_les_nouvelles_reponses(): void
|
||||
{
|
||||
$this->configurerGitea();
|
||||
Http::fake([
|
||||
'gitea.test/api/v1/repos/*/issues/42' => Http::response([
|
||||
'number' => 42, 'state' => 'open', 'comments' => 2, 'title' => '[Correction] Faute',
|
||||
]),
|
||||
]);
|
||||
|
||||
$utilisateur = $this->utilisateur();
|
||||
$this->suggestion($utilisateur);
|
||||
|
||||
$response = $this->actingAs($utilisateur)->get('/suggestions');
|
||||
|
||||
$response->assertOk()
|
||||
->assertSee('Faute dans une ressource')
|
||||
->assertSee('En cours')
|
||||
->assertSee('2 nouvelles réponses');
|
||||
}
|
||||
|
||||
public function test_la_liste_ne_montre_pas_les_suggestions_des_autres(): void
|
||||
{
|
||||
$this->configurerGitea();
|
||||
Http::fake();
|
||||
|
||||
$autre = $this->utilisateur('AutreUser', 'autre@example.com');
|
||||
$this->suggestion($autre);
|
||||
|
||||
$moi = $this->utilisateur('Moi', 'moi@example.com');
|
||||
|
||||
$this->actingAs($moi)->get('/suggestions')
|
||||
->assertOk()
|
||||
->assertDontSee('Faute dans une ressource');
|
||||
}
|
||||
|
||||
public function test_le_detail_affiche_les_reponses_du_ticket_et_les_marque_comme_vues(): void
|
||||
{
|
||||
$this->configurerGitea();
|
||||
Http::fake([
|
||||
'gitea.test/api/v1/repos/*/issues/42/comments' => Http::response([
|
||||
[
|
||||
'user' => ['username' => 'sam-depardieu'],
|
||||
'body' => 'Merci, correction prévue dans la v1.1 !',
|
||||
'created_at' => '2026-07-08T10:00:00Z',
|
||||
],
|
||||
]),
|
||||
'gitea.test/api/v1/repos/*/issues/42' => Http::response([
|
||||
'number' => 42, 'state' => 'open', 'comments' => 1, 'title' => '[Correction] Faute',
|
||||
]),
|
||||
]);
|
||||
|
||||
$utilisateur = $this->utilisateur();
|
||||
$suggestion = $this->suggestion($utilisateur);
|
||||
|
||||
$response = $this->actingAs($utilisateur)->get('/suggestions/'.$suggestion->ID_Suggestion);
|
||||
|
||||
$response->assertOk()
|
||||
->assertSee('sam-depardieu')
|
||||
->assertSee('Merci, correction prévue dans la v1.1 !');
|
||||
|
||||
// Les réponses consultées ne comptent plus comme « nouvelles »
|
||||
$this->assertSame(1, $suggestion->fresh()->CommentairesVus);
|
||||
}
|
||||
|
||||
public function test_le_detail_d_une_suggestion_d_un_autre_est_interdit(): void
|
||||
{
|
||||
$this->configurerGitea();
|
||||
Http::fake();
|
||||
|
||||
$autre = $this->utilisateur('AutreUser', 'autre@example.com');
|
||||
$suggestion = $this->suggestion($autre);
|
||||
|
||||
$moi = $this->utilisateur('Moi', 'moi@example.com');
|
||||
|
||||
$this->actingAs($moi)->get('/suggestions/'.$suggestion->ID_Suggestion)
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_l_utilisateur_peut_repondre_sur_le_ticket(): void
|
||||
{
|
||||
$this->configurerGitea();
|
||||
Http::fake([
|
||||
'gitea.test/api/v1/repos/*/issues/42/comments' => Http::response(['id' => 7], 201),
|
||||
]);
|
||||
|
||||
$utilisateur = $this->utilisateur();
|
||||
$suggestion = $this->suggestion($utilisateur);
|
||||
|
||||
$response = $this->actingAs($utilisateur)
|
||||
->post('/suggestions/'.$suggestion->ID_Suggestion.'/repondre', [
|
||||
'message' => 'Précision : la faute est dans le deuxième paragraphe.',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('suggestions.show', $suggestion));
|
||||
$response->assertSessionHas('success');
|
||||
|
||||
// Le commentaire est publié sur le ticket Gitea, signé du pseudo
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->method() === 'POST'
|
||||
&& str_contains($request->url(), '/issues/42/comments')
|
||||
&& str_contains($request['body'], 'JeanTest')
|
||||
&& str_contains($request['body'], 'deuxième paragraphe');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_une_suggestion_invalide_est_rejetee(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
$response = $this->actingAs($this->utilisateur())->post('/suggestions', [
|
||||
'type' => 'Inconnu',
|
||||
'titre' => 'x',
|
||||
'description' => 'court',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['type', 'titre', 'description']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_un_echec_gitea_affiche_une_erreur(): void
|
||||
{
|
||||
$this->configurerGitea();
|
||||
Http::fake([
|
||||
'*/api/v1/repos/*/issues' => Http::response(['message' => 'nope'], 500),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->utilisateur())->post('/suggestions', [
|
||||
'type' => 'Ajout',
|
||||
'titre' => 'Nouvelle fonctionnalité utile',
|
||||
'description' => 'Ce serait bien d\'ajouter un filtre par thème.',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('gitea');
|
||||
|
||||
// Rien n'est conservé si le ticket n'a pas pu être créé
|
||||
$this->assertDatabaseCount('suggestions', 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
class TelechargementTest extends TestCase
|
||||
{
|
||||
public function test_la_page_de_telechargement_est_accessible_publiquement(): void
|
||||
{
|
||||
$response = $this->get('/telecharger');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Android');
|
||||
$response->assertSee('iOS');
|
||||
}
|
||||
|
||||
public function test_un_lien_de_store_configure_est_utilise(): void
|
||||
{
|
||||
config(['mobile.android.url' => 'https://play.google.com/store/apps/details?id=test']);
|
||||
|
||||
$response = $this->get('/telecharger');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('play.google.com/store/apps/details?id=test');
|
||||
}
|
||||
|
||||
public function test_sans_binaire_ni_lien_le_bouton_est_indisponible(): void
|
||||
{
|
||||
config(['mobile.ios.url' => null]);
|
||||
|
||||
$response = $this->get('/telecharger');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Bientôt disponible');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user