ajout trop de chose

rappel pour moi : a ne plus reproduire
This commit is contained in:
Ploush 2026-04-09 14:58:12 +02:00
parent e6579cbb90
commit 303fc7bcb5
85 changed files with 5959 additions and 139 deletions

3
.gitignore vendored
View file

@ -1,6 +1,7 @@
.idea
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
@ -20,3 +21,5 @@
/public/assets/
/assets/vendor/
###< symfony/asset-mapper ###
/src/uploads

188
api-client.php Executable file
View file

@ -0,0 +1,188 @@
#!/usr/bin/env php
<?php
/**
* Exemple de client API PHP pour consommer votre API
*
* Usage:
* php api-client.php health
* php api-client.php profile YOUR_TOKEN
* php api-client.php keys YOUR_TOKEN
*/
class ApiClient
{
private string $baseUrl;
private string $token;
public function __construct(string $baseUrl, string $token = '')
{
$this->baseUrl = rtrim($baseUrl, '/');
$this->token = $token;
}
/**
* Effectuer une requête API
*/
private function request(string $method, string $endpoint, ?array $data = null): array
{
$url = $this->baseUrl . '/api' . $endpoint;
$options = [
'http' => [
'method' => $method,
'header' => [
'Content-Type: application/json',
],
'timeout' => 10,
],
];
if ($this->token) {
$options['http']['header'][] = "Authorization: Bearer {$this->token}";
}
if ($data && in_array($method, ['POST', 'PATCH', 'PUT'])) {
$options['http']['content'] = json_encode($data);
}
$context = stream_context_create($options);
$response = @file_get_contents($url, false, $context);
if ($response === false) {
return [
'error' => 'Erreur de connexion',
'url' => $url
];
}
return json_decode($response, true) ?? ['error' => 'Réponse invalide'];
}
// Endpoints
public function health(): array
{
return $this->request('GET', '/health');
}
public function profile(): array
{
return $this->request('GET', '/profile');
}
public function listKeys(): array
{
return $this->request('GET', '/api-keys');
}
public function createKey(string $name, ?string $expiresAt = null): array
{
$data = ['name' => $name];
if ($expiresAt) {
$data['expiresAt'] = $expiresAt;
}
return $this->request('POST', '/api-keys', $data);
}
public function deleteKey(int $id): array
{
return $this->request('DELETE', "/api-keys/{$id}");
}
public function deactivateKey(int $id): array
{
return $this->request('PATCH', "/api-keys/{$id}/deactivate");
}
}
// --- Utilisation de ligne de commande ---
if (php_sapi_name() !== 'cli') {
die('Ce script doit être exécuté en ligne de commande');
}
$baseUrl = 'http://localhost:8000';
$command = $argv[1] ?? 'health';
$token = $argv[2] ?? '';
$client = new ApiClient($baseUrl, $token);
match($command) {
'health' => output_result('Health Check', $client->health()),
'profile' => require_token($token, fn() => output_result('Profil utilisateur', $client->profile())),
'keys' => require_token($token, fn() => output_result('Clés API', $client->listKeys())),
'create-key' => require_token($token, function() use ($client) {
$name = $argv[3] ?? 'Nouvelle clé';
$expires = $argv[4] ?? null;
output_result('Création de clé', $client->createKey($name, $expires));
}),
'delete-key' => require_token($token, function() use ($client) {
if (empty($argv[3])) {
echo "Usage: php api-client.php delete-key TOKEN KEY_ID\n";
return;
}
output_result('Suppression de clé', $client->deleteKey((int)$argv[3]));
}),
'deactivate-key' => require_token($token, function() use ($client) {
if (empty($argv[3])) {
echo "Usage: php api-client.php deactivate-key TOKEN KEY_ID\n";
return;
}
output_result('Désactivation de clé', $client->deactivateKey((int)$argv[3]));
}),
default => show_help(),
};
function require_token(string $token, callable $fn)
{
if (!$token) {
echo "Erreur: Ce commande nécessite un token\n";
echo "Usage: php api-client.php COMMAND TOKEN [OPTIONS]\n";
return;
}
$fn();
}
function output_result(string $title, array $result)
{
echo "\n$title\n";
echo str_repeat('=', strlen($title)) . "\n\n";
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n\n";
}
function show_help()
{
echo <<<'EOT'
📡 Client API pour votre application
Commandes disponibles:
health - Vérifier l'état de l'API (pas de token requis)
profile TOKEN - Afficher le profil utilisateur
keys TOKEN - Lister les clés API
create-key TOKEN [NAME] [EXP] - Créer une nouvelle clé API
delete-key TOKEN KEY_ID - Supprimer une clé API
deactivate-key TOKEN KEY_ID - Désactiver une clé API
Exemples:
# Vérifier l'état
php api-client.php health
# Afficher le profil
php api-client.php profile abc123...xyz
# Lister les clés
php api-client.php keys abc123...xyz
# Créer une clé avec expiration
php api-client.php create-key abc123...xyz "Mon App" "2026-12-31"
# Supprimer une clé
php api-client.php delete-key abc123...xyz 1
EOT;
}

View file

@ -0,0 +1,11 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
navigate(e) {
window.location = this.element.dataset.href;
}
stopPropagation(e) {
e.stopPropagation();
}
}

View file

@ -1,36 +1,30 @@
import { Controller } from '@hotwired/stimulus';
/**
* Contrôleur Stimulus pour le dropdown de connexion
* Attache les événements de manière robuste et compatible avec Turbo
*/
export default class extends Controller {
static targets = ['wrapper', 'toggle', 'dropdown'];
connect() {
this.setupListeners();
// Ouvrir le dropdown s'il y a une erreur de connexion
const loginError = this.element.querySelector('.login-error');
if (loginError && this.dropdownTarget) {
this.open();
}
}
setupListeners() {
// Clic sur le bouton toggle
this.toggleTarget.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
// Clic en dehors du dropdown
document.addEventListener('click', (e) => {
if (!this.wrapperTarget.contains(e.target)) {
this.close();
}
});
// Touche Echap
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
@ -38,20 +32,16 @@ export default class extends Controller {
}
});
}
toggle() {
const isOpen = this.dropdownTarget.classList.contains('open');
isOpen ? this.close() : this.open();
}
open() {
this.dropdownTarget.classList.add('open');
this.toggleTarget.setAttribute('aria-expanded', 'true');
}
close() {
this.dropdownTarget.classList.remove('open');
this.toggleTarget.setAttribute('aria-expanded', 'false');
}
}

View file

@ -0,0 +1,26 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['row'];
connect() {
this.rowTargets.forEach(row => this.#syncRow(row));
}
toggle(event) {
this.#syncRow(event.target.closest('[data-mod-optional-target="row"]'));
}
#syncRow(row) {
const modCheckbox = row.querySelector('[data-mod-checkbox]');
const toggle = row.querySelector('[data-opt-toggle]');
const uiOpt = row.querySelector('[data-ui-opt]');
if (!modCheckbox || !toggle) return;
const checked = modCheckbox.checked;
toggle.classList.toggle('d-none', !checked);
if (!checked && uiOpt) uiOpt.checked = false;
}
}

View file

@ -0,0 +1,21 @@
// assets/controllers/modpack_download_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { url: String }
submit(event) {
event.preventDefault()
const checked = [...this.element.querySelectorAll('input[type=checkbox]:checked')]
.map(el => el.value)
const url = this.urlValue + (checked.length ? '?opt=' + checked.join('+') : '')
window.location.href = url
}
changeVersion(event) {
window.location.href = '?v=' + event.target.value
}
}

View file

@ -0,0 +1,21 @@
// assets/controllers/tom-select_controller.js
import { Controller } from '@hotwired/stimulus';
import TomSelect from 'tom-select';
export default class extends Controller {
connect() {
new TomSelect(this.element, {
plugins: ['remove_button'],
placeholder: this.element.dataset.placeholder ?? 'Rechercher une dépendance...',
maxOptions: null,
wrapperClass: 'ts-wrapper form-tom-select',
});
}
disconnect() {
if (this.element.tomselect) {
this.element.tomselect.destroy();
}
}
}

View file

@ -0,0 +1,66 @@
body {
display: flex;
flex-direction: column;
align-items: center;
}
/* Structure interne de la carte */
a.admin-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
text-decoration: none;
padding: 2rem 1.25rem;
color: var(--text-light);
transition: color var(--duration-slow);
}
a.admin-card:hover {
text-decoration: none;
}
[data-theme="dark"] a.admin-card {
color: var(--text-dark);
}
.admin-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
margin-bottom: 0.25rem;
color: var(--violet);
transition: color var(--duration-slow);
}
[data-theme="dark"] .admin-card__icon {
color: var(--violet-light);
}
.admin-card__icon svg {
width: 100%;
height: 100%;
}
.admin-card__title {
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.01em;
text-align: center;
}
.admin-card__desc {
font-size: 0.8rem;
color: var(--muted-light);
text-align: center;
line-height: 1.3;
transition: color var(--duration-slow);
}
[data-theme="dark"] .admin-card__desc {
color: var(--muted-dark);
}

View file

@ -0,0 +1,153 @@
/* ── CARTE modpack ─────────────────────────────────────────────────── */
a.modpack-card,
div.modpack-card {
display: flex;
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 0.875rem 1.25rem;
text-decoration: none;
color: var(--text-light);
transition: color var(--duration-slow);
cursor: pointer;
}
[data-theme="dark"] a.modpack-card,
[data-theme="dark"] div.modpack-card {
color: var(--text-dark);
}
a.modpack-card:hover,
div.modpack-card:hover {
text-decoration: none;
}
/* ── ICÔNE modpack ─────────────────────────────────────────────────── */
.modpack-add-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--pill-bg-light);
color: var(--violet);
border: 2px solid var(--border-light);
transition: border-color var(--duration-slow);
}
[data-theme="dark"] .modpack-add-icon {
background: var(--pill-bg-dark);
color: var(--violet-light);
border-color: var(--border-dark);
}
/* ── NOM DU modpack ────────────────────────────────────────────────── */
.modpack-name {
font-weight: 600;
font-size: 0.95rem;
min-width: 12rem;
}
/* ── ESPACE FLEXIBLE ───────────────────────────────────────────── */
.modpack-space {
flex: 1;
}
/* ── PILLS COMMUNES ────────────────────────────────────────────── */
.modpack-number,
.modpack-used,
.modpack-dep {
font-size: 0.78rem;
font-weight: 500;
padding: 0.2rem 0.6rem;
border-radius: 999px;
white-space: nowrap;
}
/* ── NUMBER ───────────────────────────────────────────────────── */
.modpack-number {
background: var(--pill-bg-light);
color: var(--violet);
}
[data-theme="dark"] .modpack-number {
background: var(--pill-bg-dark);
color: var(--violet-light);
}
/* ── DÉPENDANCES ───────────────────────────────────────────────── */
.modpack-deps {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.modpack-dep {
background: rgba(245, 158, 11, 0.10);
color: #92400e;
text-decoration: none;
}
.modpack-dep:hover {
background: rgba(245, 158, 11, 0.20);
text-decoration: none;
}
[data-theme="dark"] .modpack-dep {
background: rgba(245, 158, 11, 0.15);
color: #fcd34d;
}
[data-theme="dark"] .modpack-dep:hover {
background: rgba(245, 158, 11, 0.25);
color: #fcd34d;
}
/* ── UTILISÉ / NON UTILISÉ ─────────────────────────────────────── */
.modpack-used {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.modpack-used.non-utilise {
background: rgba(107, 114, 128, 0.1);
color: var(--muted-light);
}
[data-theme="dark"] .modpack-used.non-utilise {
color: var(--muted-dark);
}
/* ── SUPPRIMER ─────────────────────────────────────────────────── */
.modpack-delete {
font-size: 0.78rem;
font-weight: 500;
padding: 0.2rem 0.6rem;
border-radius: 999px;
white-space: nowrap;
background: rgba(239, 68, 68, 0.08);
color: #ef4444;
text-decoration: none;
transition: background var(--duration-fast), color var(--duration-fast);
}
.modpack-delete:hover {
background: rgba(239, 68, 68, 0.18);
text-decoration: none;
color: #ef4444;
}
[data-theme="dark"] .modpack-delete {
background: rgba(239, 68, 68, 0.12);
color: #fca5a5;
}
[data-theme="dark"] .modpack-delete:hover {
background: rgba(239, 68, 68, 0.22);
color: #fca5a5;
}

View file

@ -0,0 +1,153 @@
/* ── CARTE MOD ─────────────────────────────────────────────────── */
a.mod-card,
div.mod-card {
display: flex;
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 0.875rem 1.25rem;
text-decoration: none;
color: var(--text-light);
transition: color var(--duration-slow);
cursor: pointer;
}
[data-theme="dark"] a.mod-card,
[data-theme="dark"] div.mod-card {
color: var(--text-dark);
}
a.mod-card:hover,
div.mod-card:hover {
text-decoration: none;
}
/* ── ICÔNE MOD ─────────────────────────────────────────────────── */
.mod-add-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--pill-bg-light);
color: var(--violet);
border: 2px solid var(--border-light);
transition: border-color var(--duration-slow);
}
[data-theme="dark"] .mod-add-icon {
background: var(--pill-bg-dark);
color: var(--violet-light);
border-color: var(--border-dark);
}
/* ── NOM DU MOD ────────────────────────────────────────────────── */
.mod-name {
font-weight: 600;
font-size: 0.95rem;
min-width: 12rem;
}
/* ── ESPACE FLEXIBLE ───────────────────────────────────────────── */
.mod-space {
flex: 1;
}
/* ── PILLS COMMUNES ────────────────────────────────────────────── */
.mod-version,
.mod-used,
.mod-dep {
font-size: 0.78rem;
font-weight: 500;
padding: 0.2rem 0.6rem;
border-radius: 999px;
white-space: nowrap;
}
/* ── VERSION ───────────────────────────────────────────────────── */
.mod-version {
background: var(--pill-bg-light);
color: var(--violet);
}
[data-theme="dark"] .mod-version {
background: var(--pill-bg-dark);
color: var(--violet-light);
}
/* ── DÉPENDANCES ───────────────────────────────────────────────── */
.mod-deps {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.mod-dep {
background: rgba(245, 158, 11, 0.10);
color: #92400e;
text-decoration: none;
}
.mod-dep:hover {
background: rgba(245, 158, 11, 0.20);
text-decoration: none;
}
[data-theme="dark"] .mod-dep {
background: rgba(245, 158, 11, 0.15);
color: #fcd34d;
}
[data-theme="dark"] .mod-dep:hover {
background: rgba(245, 158, 11, 0.25);
color: #fcd34d;
}
/* ── UTILISÉ / NON UTILISÉ ─────────────────────────────────────── */
.mod-used {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.mod-used.non-utilise {
background: rgba(107, 114, 128, 0.1);
color: var(--muted-light);
}
[data-theme="dark"] .mod-used.non-utilise {
color: var(--muted-dark);
}
/* ── SUPPRIMER ─────────────────────────────────────────────────── */
.mod-delete {
font-size: 0.78rem;
font-weight: 500;
padding: 0.2rem 0.6rem;
border-radius: 999px;
white-space: nowrap;
background: rgba(239, 68, 68, 0.08);
color: #ef4444;
text-decoration: none;
transition: background var(--duration-fast), color var(--duration-fast);
}
.mod-delete:hover {
background: rgba(239, 68, 68, 0.18);
text-decoration: none;
color: #ef4444;
}
[data-theme="dark"] .mod-delete {
background: rgba(239, 68, 68, 0.12);
color: #fca5a5;
}
[data-theme="dark"] .mod-delete:hover {
background: rgba(239, 68, 68, 0.22);
color: #fca5a5;
}

View file

@ -0,0 +1,104 @@
/* ── CARTE UTILISATEUR ─────────────────────────────────────────── */
a.user-card {
display: flex;
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 0.875rem 1.25rem;
text-decoration: none;
color: var(--text-light);
transition: color var(--duration-slow);
}
[data-theme="dark"] a.user-card {
color: var(--text-dark);
}
a.user-card:hover {
text-decoration: none;
}
/* ── PHOTO DE PROFIL ───────────────────────────────────────────── */
.pp-img,
.pp-svg-wrapper {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
flex-shrink: 0;
object-fit: cover;
border: 2px solid var(--border-light);
transition: border-color var(--duration-slow);
}
[data-theme="dark"] .pp-img,
[data-theme="dark"] .pp-svg-wrapper {
border-color: var(--border-dark);
}
.pp-svg-wrapper {
display: flex;
align-items: center;
justify-content: center;
background: var(--pill-bg-light);
color: var(--violet);
}
[data-theme="dark"] .pp-svg-wrapper {
background: var(--pill-bg-dark);
color: var(--violet-light);
}
.pp-svg {
width: 1.1rem;
height: 1.1rem;
}
/* ── COLONNES INFOS ────────────────────────────────────────────── */
.user-pseudo {
font-weight: 600;
font-size: 0.95rem;
min-width: 10rem;
}
.user-space {
flex: 1;
overflow: hidden;
white-space: nowrap;
}
[data-theme="dark"] .user-uripp {
color: var(--muted-dark);
}
.user-role,
.user-editable {
font-size: 0.78rem;
font-weight: 500;
padding: 0.2rem 0.6rem;
border-radius: 999px;
white-space: nowrap;
}
.user-role {
background: var(--pill-bg-light);
color: var(--violet);
}
[data-theme="dark"] .user-role {
background: var(--pill-bg-dark);
color: var(--violet-light);
}
.user-editable {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.user-editable.non-modifiable {
background: rgba(107, 114, 128, 0.1);
color: var(--muted-light);
}
[data-theme="dark"] .user-editable.non-modifiable {
color: var(--muted-dark);
}

View file

@ -19,7 +19,7 @@
/* Couleurs de texte */
--text-light: #1a1625;
--text-dark: #ede9f6;
--muted-light: #6b7280;
--muted-light: #595d6a;
--muted-dark: #9ca3af;
/* Couleurs de bordure */
@ -61,13 +61,20 @@ body {
background-position: center;
background-attachment: fixed;
padding-top: 72px;
display: flex;
flex-direction: column;
align-items: center;
}
[data-theme="dark"] body {
background-image: url('../images/bg-dark.png');
}
main {
width: 100%;
max-width: 1200px;
}
/* ── STYLES POUR LE LIEN COPIABLE ──────────────────────────────── */
.copy-link {

View file

@ -26,7 +26,7 @@
color: var(--text-dark);
}
.bulle:hover{
.bulle:not(.bulle-nohover):hover{
box-shadow: 0 16px 40px rgba(124, 58, 237, 0.20);
}
@ -48,3 +48,26 @@
.bulle-container>.bulle.bulle-2-row {
grid-row: span 2;
}
/* HEADER */
.bulle-header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.25rem 1.75rem;
}
.bulle-title {
font-size: 1.6rem;
font-weight: 700;
margin: 0;
padding-left: 1rem;
color: var(--text-light);
transition: color var(--duration-slow);
}
[data-theme="dark"] .bulle-title {
color: var(--text-dark);
}

View file

@ -0,0 +1,512 @@
/* ── FORM WRAPPER ──────────────────────────────────────────────── */
.form-wrapper {
display: flex;
flex-direction: column;
gap: 2rem;
padding: 2rem;
width: 100%;
}
.form-wrapper__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.form-wrapper__col {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-wrapper__row-line {
border-bottom: 1px solid var(--border-light);
padding-bottom: 0.75rem;
margin-bottom: 0.75rem;
}
[data-theme="dark"] .form-wrapper__row-line {
border-color: var(--border-dark);
}
/* ── GROUPES ───────────────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-label {
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted-light);
transition: color var(--duration-slow);
}
[data-theme="dark"] .form-label {
color: var(--muted-dark);
}
/* ── INPUTS ────────────────────────────────────────────────────── */
.form-input {
width: 100%;
padding: 0.6rem 0.9rem;
border-radius: 10px;
border: 1px solid var(--border-light);
background-color: var(--nav-bg-light);
color: var(--text-light);
font-family: 'DM Sans', sans-serif;
font-size: 0.9rem;
outline: none;
transition: border-color var(--duration-normal), box-shadow var(--duration-normal), background var(--duration-slow), color var(--duration-slow);
appearance: none;
}
[data-theme="dark"] .form-input {
background-color: var(--nav-bg-dark);
border-color: var(--border-dark);
color: var(--text-dark);
}
.form-input:focus {
border-color: var(--violet);
box-shadow: 0 0 0 3px var(--violet-glow);
}
.form-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%237C3AED' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 0.75rem;
padding-right: 2.5rem;
}
[data-theme="dark"] .form-select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%23A78BFA' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E");
}
/* ── FILE INPUT ────────────────────────────────────────────────── */
.form-file {
width: 100%;
padding: 0.6rem 0.9rem;
border-radius: 10px;
border: 1px dashed var(--border-light);
background-color: var(--nav-bg-light);
color: var(--text-light);
font-family: 'DM Sans', sans-serif;
font-size: 0.9rem;
cursor: pointer;
outline: none;
transition: border-color var(--duration-normal),
box-shadow var(--duration-normal),
background var(--duration-slow),
color var(--duration-slow);
}
[data-theme="dark"] .form-file {
background-color: var(--nav-bg-dark);
border-color: var(--border-dark);
color: var(--text-dark);
}
.form-file:hover {
border-color: var(--violet-light);
background-color: var(--pill-bg-light);
}
[data-theme="dark"] .form-file:hover {
background-color: var(--pill-bg-dark);
border-color: var(--violet-light);
}
.form-file:focus {
border-color: var(--violet);
border-style: solid;
box-shadow: 0 0 0 3px var(--violet-glow);
}
.form-file:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Bouton natif "Choisir un fichier" */
.form-file::file-selector-button {
padding: 0.3rem 0.85rem;
border-radius: 7px;
border: none;
background: var(--pill-bg-light);
color: var(--violet);
font-family: 'DM Sans', sans-serif;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
margin-right: 0.75rem;
transition: background var(--duration-fast), color var(--duration-fast);
}
[data-theme="dark"] .form-file::file-selector-button {
background: var(--pill-bg-dark);
color: var(--violet-light);
}
.form-file:hover::file-selector-button,
.form-file:focus::file-selector-button {
background: var(--violet);
color: white;
}
/* ── HELP TEXT ─────────────────────────────────────────────────── */
.form-helper {
font-size: 0.76rem;
color: var(--muted-light);
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.3rem;
transition: color var(--duration-slow);
}
[data-theme="dark"] .form-helper {
color: var(--muted-dark);
}
/* Petite pastille d'info devant le texte */
.form-helper::before {
content: '';
font-size: 0.7rem;
color: var(--violet-light);
flex-shrink: 0;
}
/* ── FOOTER ────────────────────────────────────────────────────── */
.form-wrapper__footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.form-btn {
padding: 0.6rem 1.75rem;
border-radius: 10px;
border: none;
background: var(--violet);
color: white;
font-family: 'DM Sans', sans-serif;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity var(--duration-fast), box-shadow var(--duration-fast);
}
.form-btn:hover {
opacity: 0.9;
box-shadow: 0 4px 20px var(--violet-glow);
}
.form-btn--cancel {
background: transparent;
color: var(--muted-light);
border: 1px solid var(--border-light);
}
[data-theme="dark"] .form-btn--cancel {
color: var(--muted-dark);
border-color: var(--border-dark);
}
.form-btn--cancel:hover {
background: var(--pill-bg-light);
box-shadow: none;
opacity: 1;
text-decoration: none;
}
[data-theme="dark"] .form-btn--cancel:hover {
background: var(--pill-bg-dark);
}
/* ── ERREURS ───────────────────────────────────────────────────── */
.form-group ul {
list-style: none;
margin: 0;
padding: 0;
}
.form-group ul li {
font-size: 0.78rem;
color: #ef4444;
margin-top: 0.2rem;
}
/* ── CHECKBOX ─────────────────────────────────────────────────────── */
.form-checkbox-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-checkbox-group-item {
display: flex;
align-items: center;
gap: 0.6rem;
cursor: pointer;
font-size: 0.9rem;
color: var(--text-light);
transition: color var(--duration-slow);
}
[data-theme="dark"] .form-checkbox-group-item {
color: var(--text-dark);
}
.form-checkbox-group-item input[type="checkbox"] {
appearance: none;
width: 1.1rem;
height: 1.1rem;
border: 1.5px solid var(--border-light);
border-radius: 4px;
background: var(--nav-bg-light);
cursor: pointer;
transition: background var(--duration-normal), border-color var(--duration-normal);
flex-shrink: 0;
position: relative;
}
[data-theme="dark"] .form-checkbox-group-item input[type="checkbox"] {
border-color: var(--border-dark);
background: var(--nav-bg-dark);
}
.form-checkbox-group-item input[type="checkbox"]:checked {
background: var(--violet);
border-color: var(--violet);
}
.form-checkbox-group-item input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 4px;
height: 8px;
border: 2px solid white;
border-top: none;
border-left: none;
transform: translate(-50%, -60%) rotate(45deg);
}
/* ── TOGGLE THEME ──────────────────────────────────────────────── */
.form-toggle {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
}
.form-toggle input[type="checkbox"] {
display: none;
}
.form-toggle__track {
width: 2.4rem;
height: 1.3rem;
border-radius: 999px;
background: var(--border-light);
position: relative;
flex-shrink: 0;
transition: background var(--duration-normal);
}
.form-toggle__track::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: calc(1.3rem - 6px);
height: calc(1.3rem - 6px);
border-radius: 50%;
background: white;
transition: transform var(--duration-normal);
}
.form-toggle input:checked ~ .form-toggle__track {
background: var(--violet);
}
.form-toggle input:checked ~ .form-toggle__track::after {
transform: translateX(1.1rem);
}
.form-toggle__label {
font-size: 0.9rem;
color: var(--text-light);
transition: color var(--duration-slow);
}
.form-toggle__label.disabled {
color: var(--muted-light);
}
[data-theme="dark"] .form-toggle__label {
color: var(--text-dark);
}
[data-theme="dark"] .form-toggle__label.disabled {
color: var(--muted-dark);
}
/* ── TOM SELECT — FULL CUSTOM (sans CDN) ──────────────────────── */
.form-tom-select {
position: relative;
width: 100%;
}
.form-tom-select .ts-control {
display: flex !important;
flex-wrap: wrap !important;
align-items: center !important;
gap: 0.35rem !important;
border-radius: 10px !important;
border: 1px solid var(--border-light) !important;
background: var(--nav-bg-light) !important;
font-family: 'DM Sans', sans-serif !important;
font-size: 0.9rem !important;
padding: 0.4rem 0.6rem !important;
min-height: 2.5rem !important;
cursor: text !important;
box-shadow: none !important;
}
[data-theme="dark"] .form-tom-select .ts-control {
background: var(--nav-bg-dark) !important;
border-color: var(--border-dark) !important;
color: var(--text-dark) !important;
}
.form-tom-select.focus .ts-control {
border-color: var(--violet) !important;
box-shadow: 0 0 0 3px var(--violet-glow) !important;
outline: none !important;
}
/* ── Input de recherche ── */
.form-tom-select .ts-control input {
flex: 1 !important;
min-width: 120px !important;
border: none !important;
outline: none !important;
box-shadow: none !important;
background: transparent !important;
font-family: 'DM Sans', sans-serif !important;
font-size: 0.9rem !important;
color: var(--text-light) !important;
padding: 0 !important;
margin: 0 !important;
width: auto !important;
}
[data-theme="dark"] .form-tom-select .ts-control input {
color: var(--text-dark) !important;
}
.form-tom-select .ts-control input::placeholder {
color: var(--muted-light) !important;
opacity: 1 !important;
}
[data-theme="dark"] .form-tom-select .ts-control input::placeholder {
color: var(--muted-dark) !important;
}
/* ── Tags sélectionnés ── */
.form-tom-select .ts-control .item {
display: inline-flex !important;
align-items: center !important;
background: var(--violet) !important;
color: white !important;
border-radius: 6px !important;
padding: 0.2rem 0.5rem !important;
font-size: 0.82rem !important;
font-weight: 500 !important;
line-height: 1.4 !important;
white-space: nowrap !important;
margin: 2px !important;
}
.form-tom-select .ts-control .item .remove {
display: inline-flex !important;
align-items: center !important;
color: rgba(255, 255, 255, 0.7) !important;
border-left: 1px solid rgba(255, 255, 255, 0.3) !important;
margin-left: 0.4rem !important;
padding-left: 0.4rem !important;
font-size: 1rem !important;
line-height: 1 !important;
cursor: pointer !important;
text-decoration: none !important;
background: none !important;
}
.form-tom-select .ts-control .item .remove:hover {
color: white !important;
background: none !important;
}
/* ── Dropdown ── */
.form-tom-select .ts-dropdown {
position: absolute !important;
z-index: 1050 !important;
width: 100% !important;
border-radius: 10px !important;
border: 1px solid var(--border-light) !important;
background: var(--nav-bg-light) !important;
font-family: 'DM Sans', sans-serif !important;
font-size: 0.9rem !important;
margin-top: 4px !important;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12) !important;
overflow: hidden !important;
}
[data-theme="dark"] .form-tom-select .ts-dropdown {
background: var(--nav-bg-dark) !important;
border-color: var(--border-dark) !important;
color: var(--text-dark) !important;
}
.form-tom-select .ts-dropdown .ts-dropdown-content {
padding: 0.25rem !important;
max-height: 200px !important;
overflow-y: auto !important;
}
.form-tom-select .ts-dropdown .option {
padding: 0.5rem 0.75rem !important;
border-radius: 7px !important;
cursor: pointer !important;
color: var(--text-light) !important;
background: transparent !important;
transition: background var(--duration-fast), color var(--duration-fast) !important;
}
[data-theme="dark"] .form-tom-select .ts-dropdown .option {
color: var(--text-dark) !important;
}
.form-tom-select .ts-dropdown .option:hover,
.form-tom-select .ts-dropdown .option.active {
background: var(--violet) !important;
color: white !important;
}
.form-tom-select .ts-dropdown .option.selected {
opacity: 0.4 !important;
pointer-events: none !important;
}

View file

@ -0,0 +1,72 @@
nav .nav-link {
all: unset;
display: inline-flex !important;
align-items: center !important;
gap: 0.45rem;
white-space: nowrap;
cursor: pointer;
text-decoration: none !important;
/* Dimensions */
padding: 0.55rem 1.1rem !important;
border-radius: 10px !important;
border: 1px solid var(--border-light) !important;
background: transparent !important;
/* Typographie */
font-family: 'Syne', sans-serif !important;
font-weight: 600 !important;
font-size: 0.875rem !important;
letter-spacing: 0.03em !important;
text-transform: uppercase !important;
color: var(--muted-light) !important;
transition:
color var(--duration-normal) ease,
background var(--duration-normal) ease,
border-color var(--duration-normal) ease,
box-shadow var(--duration-normal) ease,
transform var(--duration-fast) ease !important;
}
[data-theme="dark"] nav .nav-link {
border-color: var(--border-dark) !important;
color: var(--muted-dark) !important;
}
/* Hover */
nav .nav-link:hover {
color: var(--violet) !important;
background: rgba(0, 0, 0, 0.05) !important;
border-color: rgba(0, 0, 0, 0.15) !important;
transform: translateY(-1px) !important;
box-shadow: none !important;
}
[data-theme="dark"] nav .nav-link:hover {
color: var(--violet-light) !important;
background: rgba(255, 255, 255, 0.07) !important;
border-color: rgba(255, 255, 255, 0.15) !important;
}
/* Actif — fond violet léger + bordure violet */
nav .nav-link.active {
color: var(--violet) !important;
background: var(--pill-bg-light) !important;
border-color: rgba(124, 58, 237, 0.30) !important;
font-weight: 700 !important;
}
[data-theme="dark"] nav .nav-link.active {
color: var(--violet-light) !important;
background: var(--pill-bg-dark) !important;
border-color: rgba(167, 139, 250, 0.30) !important;
}
/* Actif + hover — glow subtil */
nav .nav-link.active:hover {
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.20) !important;
}
/* Pas de pseudo-élément soulignement */
nav .nav-link::before,
nav .nav-link::after { display: none !important; }

View file

@ -0,0 +1,134 @@
/* ================================================================
assets/styles/partials/flash.css
Flash messages thème clair & sombre
================================================================ */
/* ── CONTENEUR ─────────────────────────────────────────────────── */
.flash-container {
position: fixed;
top: calc(72px + 1.25rem);
left: 50%;
transform: translateX(-50%);
z-index: 200;
display: flex;
flex-direction: column;
gap: 0.6rem;
width: 440px;
max-width: calc(100vw - 2rem);
pointer-events: none;
}
/* ── FLASH DE BASE ─────────────────────────────────────────────── */
.flash {
pointer-events: all;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0.75rem;
padding: 0.9rem 0.9rem 0.9rem 1.1rem;
border-radius: 16px;
border: 1px solid transparent;
backdrop-filter: blur(6px) saturate(1.8);
-webkit-backdrop-filter: blur(6px) saturate(1.8);
font-family: 'DM Sans', sans-serif;
font-size: 0.875rem;
font-weight: 400;
line-height: 1.5;
animation: flash-in 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
/* ── COULEURS PAR TYPE — light ──────────────────────────────────── */
.flash-success {
background: rgba(16, 185, 129, 0.20);
border-color: rgba(16, 185, 129, 0.50);
color: #064e3b;
box-shadow: 0 2px 0 rgba(255,255,255,0.50) inset, 0 8px 32px rgba(16,185,129,0.20);
}
.flash-danger {
background: rgba(239, 68, 68, 0.20);
border-color: rgba(239, 68, 68, 0.50);
color: #7f1d1d;
box-shadow: 0 2px 0 rgba(255,255,255,0.50) inset, 0 8px 32px rgba(239,68,68,0.20);
}
.flash-warning {
background: rgba(245, 158, 11, 0.20);
border-color: rgba(245, 158, 11, 0.50);
color: #78350f;
box-shadow: 0 2px 0 rgba(255,255,255,0.50) inset, 0 8px 32px rgba(245,158,11,0.20);
}
.flash-info {
background: rgba(124, 58, 237, 0.18);
border-color: rgba(124, 58, 237, 0.50);
color: #2e1065;
box-shadow: 0 2px 0 rgba(255,255,255,0.50) inset, 0 8px 32px rgba(124,58,237,0.20);
}
/* ── COULEURS PAR TYPE — dark ───────────────────────────────────── */
[data-theme="dark"] .flash-success {
background: rgba(16, 185, 129, 0.18);
border-color: rgba(16, 185, 129, 0.45);
color: #6ee7b7;
box-shadow: 0 1px 0 rgba(255,255,255,0.06) inset, 0 8px 32px rgba(16,185,129,0.18);
}
[data-theme="dark"] .flash-danger {
background: rgba(239, 68, 68, 0.18);
border-color: rgba(239, 68, 68, 0.45);
color: #fca5a5;
box-shadow: 0 1px 0 rgba(255,255,255,0.06) inset, 0 8px 32px rgba(239,68,68,0.18);
}
[data-theme="dark"] .flash-warning {
background: rgba(245, 158, 11, 0.18);
border-color: rgba(245, 158, 11, 0.45);
color: #fcd34d;
box-shadow: 0 1px 0 rgba(255,255,255,0.06) inset, 0 8px 32px rgba(245,158,11,0.18);
}
[data-theme="dark"] .flash-info {
background: rgba(124, 58, 237, 0.20);
border-color: rgba(167, 139, 250, 0.45);
color: #c4b5fd;
box-shadow: 0 1px 0 rgba(255,255,255,0.06) inset, 0 8px 32px rgba(124,58,237,0.20);
}
/* ── ANIMATION ─────────────────────────────────────────────────── */
@keyframes flash-in {
from { opacity: 0; transform: translateY(-40px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── TEXTE ─────────────────────────────────────────────────────── */
.flash-body {
min-width: 0;
}
/* ── BOUTON FERMER ─────────────────────────────────────────────── */
.flash-close {
flex-shrink: 0;
width: 26px;
height: 26px;
min-width: 26px;
padding: 0;
margin: 0;
background: rgba(255, 255, 255, 0.10);
border: none;
border-radius: 7px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: currentColor;
font-size: 0.7rem;
opacity: 0.6;
transition: opacity var(--duration-fast), background var(--duration-fast);
}
.flash-close:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.20);
}
/* ── MOBILE ────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.flash-container { width: calc(100vw - 2rem); }
}

View file

@ -3,6 +3,8 @@
Styles du header thème clair & sombre
================================================================ */
@import '../globals/navbutton.css';
/* ── HEADER ────────────────────────────────────────────────────── */
header {
position: fixed;
@ -73,80 +75,6 @@ header nav {
flex-wrap: nowrap;
}
/* ── LIENS DE NAVIGATION ───────────────────────────────────────── */
header nav .nav-link {
all: unset;
display: inline-flex !important;
align-items: center !important;
gap: 0.45rem;
white-space: nowrap;
cursor: pointer;
text-decoration: none !important;
/* Dimensions */
padding: 0.55rem 1.1rem !important;
border-radius: 10px !important;
border: 1px solid var(--border-light) !important;
background: transparent !important;
/* Typographie */
font-family: 'Syne', sans-serif !important;
font-weight: 600 !important;
font-size: 0.875rem !important;
letter-spacing: 0.03em !important;
text-transform: uppercase !important;
color: var(--muted-light) !important;
transition:
color var(--duration-normal) ease,
background var(--duration-normal) ease,
border-color var(--duration-normal) ease,
box-shadow var(--duration-normal) ease,
transform var(--duration-fast) ease !important;
}
[data-theme="dark"] header nav .nav-link {
border-color: var(--border-dark) !important;
color: var(--muted-dark) !important;
}
/* Hover */
header nav .nav-link:hover {
color: var(--violet) !important;
background: rgba(0, 0, 0, 0.05) !important;
border-color: rgba(0, 0, 0, 0.15) !important;
transform: translateY(-1px) !important;
box-shadow: none !important;
}
[data-theme="dark"] header nav .nav-link:hover {
color: var(--violet-light) !important;
background: rgba(255, 255, 255, 0.07) !important;
border-color: rgba(255, 255, 255, 0.15) !important;
}
/* Actif — fond violet léger + bordure violet */
header nav .nav-link.active {
color: var(--violet) !important;
background: var(--pill-bg-light) !important;
border-color: rgba(124, 58, 237, 0.30) !important;
font-weight: 700 !important;
}
[data-theme="dark"] header nav .nav-link.active {
color: var(--violet-light) !important;
background: var(--pill-bg-dark) !important;
border-color: rgba(167, 139, 250, 0.30) !important;
}
/* Actif + hover — glow subtil */
header nav .nav-link.active:hover {
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.20) !important;
}
/* Pas de pseudo-élément soulignement */
header nav .nav-link::before,
header nav .nav-link::after { display: none !important; }
/* ── LIENS DROPDOWN ─────────── */
header .header-dropdown .dropdown-link {
all: unset;
@ -387,9 +315,54 @@ header .header-dropdown .dropdown-link::after { display: none !important; }
transform: translateY(-1px);
}
/* ── PHOTO DE PROFIL ───────────────────────────────────────────── */
.profile-pic {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover; /* crop centré, pas d'étirement */
object-position: center;
border: 1.5px solid var(--border-light);
background: rgba(0, 0, 0, 0.06); /* fallback si l'image met du temps */
flex-shrink: 0;
display: block;
transition: border-color var(--duration-normal) ease;
}
[data-theme="dark"] .profile-pic {
border-color: var(--border-dark);
}
/* Légère surbrillance au hover du bouton parent */
.login-btn:hover .profile-pic {
border-color: rgba(255, 255, 255, 0.5);
}
/* ── MOBILE ────────────────────────────────────────────────────── */
@media (max-width: 640px) {
header nav { display: none !important; }
header { padding: 0 1rem !important; }
.header-left { gap: 0; }
}
/* ── MESSAGE D'ERREUR DANS LE DROPDOWN ─────────────────────────── */
.login-error {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.login-error .alert-danger {
padding: 0.75rem;
border-radius: 8px;
border: 1px solid #dc3545;
background: rgba(220, 53, 69, 0.1);
color: #dc3545;
font-size: 0.813rem;
font-weight: 500;
}
[data-theme="dark"] .login-error .alert-danger {
border-color: #ff6b7a;
background: rgba(255, 107, 122, 0.15);
color: #ff8a9b;
}

View file

@ -1,9 +0,0 @@
body{
display: flex;
flex-direction: column;
align-items: center;
}
main {
width: 100%;
max-width: 1200px;
}

View file

@ -0,0 +1 @@

View file

@ -42,7 +42,8 @@
"symfony/web-link": "7.4.*",
"symfony/yaml": "7.4.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
"twig/twig": "^2.12|^3.0",
"ext-zip": "*"
},
"config": {
"allow-plugins": {

View file

@ -1,4 +1,9 @@
security:
# Hiérarchie des rôles - les rôles enfants héritent des rôles parents
role_hierarchy:
ROLE_ADMIN: [ROLE_USER]
ROLE_MODERATOR: [ROLE_USER]
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
@ -10,25 +15,47 @@ security:
entity:
class: App\Entity\User
property: pseudo
api_user_provider:
entity:
class: App\Entity\User
property: pseudo
firewalls:
dev:
# Ensure dev tools and static assets are always allowed
pattern: ^/(_profiler|_wdt|assets|build)/
security: false
api:
pattern: ^/api
provider: app_user_provider
context: app_security
stateless: false
lazy: false
# Charger la session du context partagé automatiquement
# Cela permet à Symfony d'authentifier les utilisateurs connectés
# http_basic agit ici comme un fallback pour accepter les sessions
http_basic: ~
# Authenticateur pour gérer les tokens API (Bearer)
# Si pas de Bearer token, la session du context est utilisée
custom_authenticators:
- App\Security\ApiBearerTokenAuthenticator
main:
provider: app_user_provider
context: app_security # Utiliser le même contexte
form_login:
login_path: app_login # route GET (affichage formulaire)
check_path: app_login # route POST (traitement)
username_parameter: _username
password_parameter: _password
default_target_path: app_accueil
target_path_parameter: _target_path
failure_path: /
use_referer: true
enable_csrf: true
failure_handler: App\Security\LoginFailureHandler
success_handler: App\Security\LoginSuccessHandler
logout:
path: app_logout
# where to redirect after logout
# target: app_any_route
# Activate different ways to authenticate:
# https://symfony.com/doc/current/security.html#the-firewall
@ -38,8 +65,8 @@ security:
# Note: Only the *first* matching rule is applied
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
- { path: ^/administration, roles: ROLE_ADMIN }
- { path: ^/profile, roles: ROLE_USER }
when@test:
security:

View file

@ -6,6 +6,13 @@
# To list all registered routes, run the following command:
# bin/console debug:router
api_v1:
resource: ../src/Controller/Api/V1/
type: attribute
prefix: /api/v1
controllers:
resource: routing.controllers
resource: ../src/Controller/
type: attribute
exclude:
- ../src/Controller/Api/

View file

@ -7,12 +7,17 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
app.upload_dir: '%kernel.project_dir%/public/uploads'
app.tmp_dir: '%kernel.project_dir%/var/tmp'
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
bind:
$uploadDir: '%app.upload_dir%' # bind the $uploadDir argument to the app.upload_dir parameter
$tmpDir: '%app.tmp_dir%' # bind the $tmpDir argument to the app.tmp_dir parameter
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
@ -21,3 +26,4 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

View file

@ -0,0 +1,825 @@
# 📚 Documentation Complète API mcV3
## Table des matières
1. [Architecture générale](#architecture-générale)
2. [Authentification](#authentification)
3. [Endpoints API](#endpoints-api)
4. [Gestion des clés API](#gestion-des-clés-api)
5. [Rôles et permissions](#rôles-et-permissions)
6. [Codes HTTP et erreurs](#codes-http-et-erreurs)
7. [Intégrations](#intégrations)
8. [Configuration avancée](#configuration-avancée)
9. [FAQ et dépannage](#faq-et-dépannage)
---
## Architecture générale
### Vue d'ensemble
Votre API utilise une **authentification hybride** combinant deux modes:
1. **Token API (Bearer Token)** - Pour applications externes/services
2. **Session utilisateur** - Pour utilisateurs connectés au site web
```
┌─────────────────────────────────────────────┐
│ Requête /api/* │
└────────────────┬────────────────────────────┘
┌───────┴──────────┐
│ │
┌────▼─────┐ ┌───────▼──────┐
│ Bearer │ │ Session │
│ Token? │ │ Active? │
└────┬─────┘ └───────┬──────┘
│ │
┌────▼──────────────────▼────┐
│ ApiTokenHandler OR │
│ Session Storage │
└────┬──────────────────────┘
┌────▼──────────────────────┐
│ Load User + Roles │
└────┬──────────────────────┘
┌────▼──────────────────────┐
│ Verify @IsGranted() │
└────┬──────────────────────┘
✅ 200 OK ou ❌ 401/403
```
### Flux de sécurité
1. **Firewall `/api`** - Accepte les requêtes `/api/*`
2. **ApiBearerTokenAuthenticator** - Vérifie les Bearer tokens
3. **http_basic** - Charge la session du context partagé
4. **Contrôles d'accès** - Vérifie `@IsGranted()` sur les routes
5. **ApiExceptionListener** - Convertit les erreurs en JSON
---
## Authentification
### Mode 1: Bearer Token (Clé API)
#### Génération du token
**Via CLI:**
```bash
php bin/console app:api-key:create pseudo_utilisateur "Nom de l'application"
```
Le token est généré automatiquement (64 caractères hexadécimaux, cryptographiquement sécurisé).
**Via API (utilisateur connecté):**
```http
POST /api/api-keys
Content-Type: application/json
Authorization: Bearer existing_token
{
"name": "Application mobile",
"expiresAt": "2027-04-03T23:59:59Z" // optionnel
}
```
#### Utilisation du token
Le token doit être envoyé dans l'en-tête `Authorization`:
```http
GET /api/profile
Authorization: Bearer abc123def456...
```
#### Caractéristiques
- ✅ Unique par clé API
- ✅ Peut avoir une date d'expiration
- ✅ Peut être désactivé/révoqué
- ✅ Stateless (pas de session serveur)
- ✅ Audit (tracking du dernier accès)
### Mode 2: Session utilisateur
#### Établir une session
```http
POST /login
Content-Type: application/x-www-form-urlencoded
_username=john&_password=secret
```
Un cookie `PHPSESSID` est créé et stocké.
#### Utiliser la session
Tous les cookies sont envoyés automatiquement par le navigateur. Via cURL:
```bash
curl -b cookies.txt -X POST http://localhost:8000/login \
-d "_username=john&_password=secret"
curl -b cookies.txt http://localhost:8000/api/profile
```
#### Caractéristiques
- ✅ Basée sur les cookies
- ✅ Automatique dans le navigateur
- ✅ Partage avec le firewall `main`
- ✅ Timeout configurable
- ✅ Plus sécurisée pour les applications web
### Détection du mode d'authentification
La réponse `/api/profile` indique le mode utilisé:
```json
{
"id": 1,
"pseudo": "john",
"roles": ["ROLE_USER"],
"authenticatedVia": "api_key" // ou "session"
}
```
---
## Endpoints API
### GET /api/health
**Authentification**: ❌ Non requise
Vérifier l'état de l'API.
**Exemple:**
```bash
curl http://localhost:8000/api/health
```
**Réponse (200 OK):**
```json
{
"status": "ok",
"timestamp": "2026-04-03T10:30:00+00:00"
}
```
---
### GET /api/profile
**Authentification**: ✅ Requise (Bearer token ou session)
Récupérer le profil de l'utilisateur authentifié.
**Exemple avec Bearer token:**
```bash
curl -X GET http://localhost:8000/api/profile \
-H "Authorization: Bearer your_token"
```
**Réponse (200 OK):**
```json
{
"id": 1,
"pseudo": "john",
"roles": ["ROLE_USER", "ROLE_ADMIN"],
"authenticatedVia": "api_key"
}
```
**Erreurs possibles:**
- `401 Unauthorized` - Token invalide/expiré ou pas de session
---
### GET /api/api-keys
**Authentification**: ✅ Requise (Bearer token ou session)
Lister les clés API.
- **ROLE_USER**: Voit uniquement SES propres clés
- **ROLE_ADMIN**: Voit TOUTES les clés du système
**Exemple:**
```bash
curl -X GET http://localhost:8000/api/api-keys \
-H "Authorization: Bearer your_token"
```
**Réponse (200 OK):**
```json
{
"apiKeys": [
{
"id": 1,
"name": "Mobile App",
"token": "a1b2c3d4e5...", // masqué pour sécurité
"createdAt": "2026-04-01T10:00:00+00:00",
"lastUsedAt": "2026-04-03T15:30:00+00:00",
"expiresAt": "2026-12-31T23:59:59+00:00",
"isActive": true
}
]
}
```
---
### POST /api/api-keys
**Authentification**: ✅ Requise (Bearer token ou session)
Créer une nouvelle clé API.
**Body (JSON):**
```json
{
"name": "Nouvelle application",
"expiresAt": "2027-04-03T23:59:59Z" // optionnel
}
```
**Exemple:**
```bash
curl -X POST http://localhost:8000/api/api-keys \
-H "Authorization: Bearer your_token" \
-H "Content-Type: application/json" \
-d '{
"name": "API Desktop",
"expiresAt": "2026-12-31T23:59:59Z"
}'
```
**Réponse (201 Created):**
```json
{
"id": 5,
"name": "API Desktop",
"token": "abc123def456...xyz", // Complet à la création uniquement!
"createdAt": "2026-04-03T16:45:00+00:00",
"message": "Copier le token en lieu sûr, il ne sera plus visible ultérieurement"
}
```
**⚠️ IMPORTANT**: Le token complet n'est affiché qu'à la création. Stockez-le immédiatement.
**Erreurs possibles:**
- `400 Bad Request` - Paramètre "name" manquant
- `401 Unauthorized` - Authentification invalide
---
### DELETE /api/api-keys/{id}
**Authentification**: ✅ Requise (Bearer token ou session)
Supprimer une clé API.
- **ROLE_USER**: Peut supprimer uniquement ses propres clés
- **ROLE_ADMIN**: Peut supprimer n'importe quelle clé
**Exemple:**
```bash
curl -X DELETE http://localhost:8000/api/api-keys/1 \
-H "Authorization: Bearer your_token"
```
**Réponse (200 OK):**
```json
{
"message": "API key deleted"
}
```
**Erreurs possibles:**
- `403 Forbidden` - Essai de supprimer la clé d'un autre utilisateur
- `404 Not Found` - Clé inexistante
---
### PATCH /api/api-keys/{id}/deactivate
**Authentification**: ✅ Requise (Bearer token ou session)
Désactiver une clé API sans la supprimer.
**Exemple:**
```bash
curl -X PATCH http://localhost:8000/api/api-keys/1/deactivate \
-H "Authorization: Bearer your_token"
```
**Réponse (200 OK):**
```json
{
"message": "API key deactivated"
}
```
---
## Gestion des clés API
### Cycle de vie d'une clé
```
Création
├─→ Actif et utilisable
├─→ Peut être désactivé (sans suppression)
├─→ Peut avoir une expiration automatique
└─→ Peut être supprimé définitivement
```
### Bonnes pratiques
1. **Créer des clés par application** - Une clé par service/app
2. **Définir une expiration** - Rotation annuelle recommandée
3. **Révoquer régulièrement** - Supprimer les vieilles clés
4. **Ne jamais partager** - Chaque clé est personnelle
5. **Monitorer l'accès** - Vérifier `lastUsedAt`
### Exemple: Rotation de clés
```bash
# 1. Créer une nouvelle clé
curl -X POST http://localhost:8000/api/api-keys \
-H "Authorization: Bearer old_token" \
-H "Content-Type: application/json" \
-d '{"name": "Mobile App v2"}'
# 2. Mettre à jour l'application avec la nouvelle clé
# ... déployer avec new_token ...
# 3. Désactiver l'ancienne clé
curl -X PATCH http://localhost:8000/api/api-keys/1/deactivate \
-H "Authorization: Bearer new_token"
# 4. (Optionnel) Supprimer après confirmation
curl -X DELETE http://localhost:8000/api/api-keys/1 \
-H "Authorization: Bearer new_token"
```
---
## Rôles et permissions
### Hiérarchie de rôles
```
ROLE_ADMIN
└── ROLE_USER
ROLE_MODERATOR
└── ROLE_USER
ROLE_USER (base)
```
### Permissions par rôle
| Action | ROLE_USER | ROLE_ADMIN |
|--------|-----------|-----------|
| Voir son profil | ✅ | ✅ |
| Voir ses clés | ✅ | ✅ |
| Voir les clés d'autres | ❌ | ✅ |
| Créer ses clés | ✅ | ✅ |
| Supprimer ses clés | ✅ | ✅ |
| Supprimer les clés d'autres | ❌ | ✅ |
| Accéder à /api/* | ✅ | ✅ |
| Accéder à /administration | ❌ | ✅ |
### Implémentation dans le code
```php
// Protéger une route par rôle
#[Route('/api/mon-endpoint')]
#[IsGranted('ROLE_USER')]
public function monEndpoint(): JsonResponse { }
// Logique conditionnelle selon le rôle
if ($this->isGranted('ROLE_ADMIN')) {
// Admin seulement
return $this->json($allData);
}
return $this->json($userData);
```
---
## Codes HTTP et erreurs
### Codes de succès
| Code | Signification | Utilisation |
|------|---------------|-------------|
| 200 | OK | Requête réussie (GET, POST, PATCH, DELETE) |
| 201 | Created | Ressource créée (POST) |
| 204 | No Content | Succès sans contenu (rare) |
### Codes d'erreur client (4xx)
#### 400 Bad Request
Requête invalide (paramètres manquants, JSON malformé).
**Exemple:**
```json
{
"error": "Bad Request",
"message": "name is required"
}
```
**Solutions:**
- Vérifier le format JSON
- Ajouter les paramètres requis
#### 401 Unauthorized
Non authentifié ou authentification invalide.
**Cas courants:**
- Token manquant: `curl http://localhost:8000/api/profile`
- Token invalide: `Authorization: Bearer wrong_token`
- Token expiré: Token dont la date d'expiration est passée
- Session expirée: Cookies invalides/écoulés
**Réponse:**
```json
{
"error": "Unauthorized",
"message": "Authentication required. Please provide a valid API token or be logged in."
}
```
**Solutions:**
- Ajouter le header `Authorization: Bearer YOUR_TOKEN`
- Renouveler le token expiré
- Se reconnecter (pour session)
#### 403 Forbidden
Authentifié mais permissions insuffisantes.
**Cas courants:**
- ROLE_USER essayant de supprimer la clé d'un autre
- Accès à un endpoint réservé aux admins
**Réponse:**
```json
{
"error": "Forbidden",
"message": "You do not have permission to access this resource"
}
```
**Solutions:**
- Utiliser un compte admin si nécessaire
- Vérifier les droits requis
#### 404 Not Found
Ressource inexistante.
**Cas courants:**
- Clé API inexistante: `DELETE /api/api-keys/99999`
- Endpoint invalide: `GET /api/nonexistent`
**Solutions:**
- Vérifier l'ID de la ressource
- Vérifier le chemin de la route
### Codes d'erreur serveur (5xx)
#### 500 Internal Server Error
Erreur serveur générique.
**Cause:** Bug ou exception non gérée.
**Solutions:**
- Vérifier les logs: `tail -f var/log/dev.log`
- Contacter le développeur
---
## Intégrations
### JavaScript / Fetch API
```javascript
// Configuration
const API_BASE_URL = 'http://localhost:8000';
const API_TOKEN = 'your_api_token';
// Helper pour les appels API
async function apiCall(endpoint, options = {}) {
const url = `${API_BASE_URL}${endpoint}`;
const headers = {
'Authorization': `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
...options.headers,
};
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json();
throw new Error(`${response.status}: ${error.message}`);
}
return response.json();
}
// Utilisation
async function getProfile() {
try {
const profile = await apiCall('/api/profile');
console.log('Profil:', profile);
} catch (error) {
console.error('Erreur:', error.message);
}
}
async function createApiKey(name) {
try {
const key = await apiCall('/api/api-keys', {
method: 'POST',
body: JSON.stringify({ name }),
});
console.log('Clé créée:', key.token);
} catch (error) {
console.error('Erreur:', error.message);
}
}
// Appels
getProfile();
createApiKey('Mon App');
```
### Python / Requests
```python
import requests
import json
class ApiClient:
def __init__(self, base_url, api_token):
self.base_url = base_url
self.api_token = api_token
self.session = requests.Session()
self.session.headers.update({
'Authorization': f'Bearer {api_token}',
'Content-Type': 'application/json',
})
def request(self, method, endpoint, data=None):
url = f"{self.base_url}{endpoint}"
try:
if method == 'GET':
response = self.session.get(url)
elif method == 'POST':
response = self.session.post(url, json=data)
elif method == 'DELETE':
response = self.session.delete(url)
elif method == 'PATCH':
response = self.session.patch(url, json=data)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
print(f"Erreur: {e}")
return None
def get_profile(self):
return self.request('GET', '/api/profile')
def list_keys(self):
return self.request('GET', '/api/api-keys')
def create_key(self, name, expires_at=None):
data = {'name': name}
if expires_at:
data['expiresAt'] = expires_at
return self.request('POST', '/api/api-keys', data)
# Utilisation
client = ApiClient('http://localhost:8000', 'your_api_token')
profile = client.get_profile()
print(f"Profil: {profile}")
keys = client.list_keys()
print(f"Clés: {keys}")
new_key = client.create_key('Python App')
print(f"Nouvelle clé: {new_key['token']}")
```
### PHP / cURL
```php
class ApiClient {
private $baseUrl;
private $apiToken;
public function __construct($baseUrl, $apiToken) {
$this->baseUrl = rtrim($baseUrl, '/');
$this->apiToken = $apiToken;
}
private function request($method, $endpoint, $data = null) {
$url = $this->baseUrl . $endpoint;
$options = [
'http' => [
'method' => $method,
'header' => [
"Authorization: Bearer {$this->apiToken}",
'Content-Type: application/json',
],
'timeout' => 10,
],
];
if ($data && in_array($method, ['POST', 'PATCH'])) {
$options['http']['content'] = json_encode($data);
}
$context = stream_context_create($options);
$response = @file_get_contents($url, false, $context);
if ($response === false) {
return ['error' => 'Erreur de connexion'];
}
return json_decode($response, true);
}
public function getProfile() {
return $this->request('GET', '/api/profile');
}
public function listKeys() {
return $this->request('GET', '/api/api-keys');
}
public function createKey($name, $expiresAt = null) {
$data = ['name' => $name];
if ($expiresAt) {
$data['expiresAt'] = $expiresAt;
}
return $this->request('POST', '/api/api-keys', $data);
}
}
// Utilisation
$client = new ApiClient('http://localhost:8000', 'your_api_token');
$profile = $client->getProfile();
var_dump($profile);
$keys = $client->listKeys();
var_dump($keys);
$newKey = $client->createKey('PHP App');
echo "Nouvelle clé: " . $newKey['token'];
```
---
## Configuration avancée
### Ajouter des endpoints personnalisés
```php
// Dans src/Controller/ApiController.php
#[Route('/api/custom-endpoint')]
#[IsGranted('ROLE_USER')]
public function customEndpoint(): JsonResponse
{
$user = $this->getUser();
return $this->json([
'message' => 'Données personnalisées',
'user' => $user->getPseudo(),
]);
}
```
### Filtrer par rôle dans un endpoint
```php
#[Route('/api/admin-data')]
#[IsGranted('ROLE_USER')]
public function adminData(): JsonResponse
{
if (!$this->isGranted('ROLE_ADMIN')) {
return $this->json(
['error' => 'Accès admin requis'],
JsonResponse::HTTP_FORBIDDEN
);
}
return $this->json([
'adminData' => 'Secret data',
]);
}
```
### Valider les requêtes
```php
#[Route('/api/create-item', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function createItem(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
// Valider les paramètres requis
if (empty($data['name'])) {
return $this->json(
['error' => 'name is required'],
JsonResponse::HTTP_BAD_REQUEST
);
}
// Traiter les données...
return $this->json(['id' => 1, 'name' => $data['name']]);
}
```
---
## FAQ et dépannage
### Q: Erreur "Invalid or expired API token"
**R:** Le token est invalide ou expiré.
- Vérifier que le token est correct
- Créer une nouvelle clé: `php bin/console app:api-key:create`
- Vérifier l'expiration
### Q: Comment mettre à jour ma clé?
**R:** Les clés ne peuvent pas être mises à jour. Solutions:
- Créer une nouvelle clé avec une date d'expiration plus lointaine
- Révoquer l'ancienne clé
### Q: Puis-je utiliser la même clé pour plusieurs applications?
**R:** Techniquement oui, mais **déconseillé** pour des raisons de sécurité:
- Une compromission affecte toutes les applications
- Impossible de révoquer une seule application
- Créer une clé par application
### Q: Comment changer l'expiration d'une clé?
**R:** Ce n'est pas possible directement. Options:
1. Créer une nouvelle clé avec expiration souhaitée
2. Révoquer l'ancienne
3. Mettre à jour l'application
### Q: L'API fonctionne-t-elle avec HTTPS?
**R:** Oui, recommandé en production. Assurez-vous que:
- Les certificats sont valides
- Les URLs sont en HTTPS
- Les cookies ont le flag `Secure`
### Q: Puis-je accéder à l'API depuis un autre domaine (CORS)?
**R:** CORS n'est pas configuré par défaut. Pour l'activer:
1. Installer bundle CORS: `composer require nelmio/cors-bundle`
2. Configurer les domaines autorisés
### Q: Comment monitorer l'utilisation de l'API?
**R:** Vérifier:
- `lastUsedAt` dans GET `/api/api-keys`
- Les logs: `var/log/dev.log`
- Implémenter un logging personnalisé
### Q: Erreur "403 Forbidden"
**R:** Vous n'avez pas les permissions. Vérifier:
- Votre rôle (GET `/api/profile`)
- Les droits requis (@IsGranted)
- Si vous essayez de modifier les données d'un autre
### Q: Comment réinitialiser toutes mes clés?
**R:**
1. Lister les clés: `GET /api/api-keys`
2. Supprimer une par une: `DELETE /api/api-keys/{id}`
3. Créer de nouvelles clés
---
**Documentation à jour: 2026-04-03**

View file

@ -0,0 +1,207 @@
# 🚀 Guide rapide API - Démarrage en 5 minutes
## 1⃣ Créer une clé API
### Via ligne de commande
```bash
php bin/console app:api-key:create votre_pseudo "Nom de votre application"
```
**Exemple:**
```bash
php bin/console app:api-key:create john "Mon App Mobile"
```
Vous recevrez un **token unique** - conservez-le en lieu sûr!
### Via l'API (pour utilisateurs connectés)
```bash
curl -X POST http://localhost:8000/api/api-keys \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Nom de votre clé"}'
```
---
## 2⃣ Accéder à l'API
### Option A: Avec une clé API (recommandé pour applications externes)
```bash
curl -X GET http://localhost:8000/api/profile \
-H "Authorization: Bearer your_api_token_here"
```
**Exemple de réponse:**
```json
{
"id": 1,
"pseudo": "john",
"roles": ["ROLE_USER"],
"authenticatedVia": "api_key"
}
```
### Option B: Via session (pour utilisateurs connectés au site)
```bash
# Après connexion au site web
curl http://localhost:8000/api/profile
```
La session est automatiquement utilisée.
---
## 3⃣ Endpoints disponibles
| Méthode | Route | Authentification | Description |
|---------|-------|------------------|-------------|
| `GET` | `/api/health` | ❌ Non | Vérifier l'état de l'API |
| `GET` | `/api/profile` | ✅ Oui | Votre profil utilisateur |
| `GET` | `/api/api-keys` | ✅ Oui | Vos clés API / toutes (admin) |
| `POST` | `/api/api-keys` | ✅ Oui | Créer une nouvelle clé |
| `DELETE` | `/api/api-keys/{id}` | ✅ Oui | Supprimer une clé |
| `PATCH` | `/api/api-keys/{id}/deactivate` | ✅ Oui | Désactiver une clé |
---
## 4⃣ Exemples pratiques
### Vérifier l'état de l'API
```bash
curl http://localhost:8000/api/health
```
### Récupérer votre profil
```bash
curl -X GET http://localhost:8000/api/profile \
-H "Authorization: Bearer your_token"
```
### Lister vos clés API
```bash
curl -X GET http://localhost:8000/api/api-keys \
-H "Authorization: Bearer your_token"
```
### Créer une nouvelle clé
```bash
curl -X POST http://localhost:8000/api/api-keys \
-H "Authorization: Bearer your_token" \
-H "Content-Type: application/json" \
-d '{"name": "Nouvelle clé", "expiresAt": "2027-04-03T00:00:00Z"}'
```
### Supprimer une clé
```bash
curl -X DELETE http://localhost:8000/api/api-keys/1 \
-H "Authorization: Bearer your_token"
```
---
## 5⃣ Utilisation dans votre application
### JavaScript / Fetch
```javascript
const apiToken = 'your_api_token';
async function getProfile() {
const response = await fetch('http://localhost:8000/api/profile', {
headers: {
'Authorization': `Bearer ${apiToken}`
}
});
if (!response.ok) {
throw new Error(`Erreur: ${response.status}`);
}
return response.json();
}
getProfile().then(profile => console.log(profile));
```
### Python / Requests
```python
import requests
api_token = 'your_api_token'
headers = {'Authorization': f'Bearer {api_token}'}
response = requests.get('http://localhost:8000/api/profile', headers=headers)
response.raise_for_status()
print(response.json())
```
### PHP / cURL
```php
$token = 'your_api_token';
$url = 'http://localhost:8000/api/profile';
$options = [
'http' => [
'method' => 'GET',
'header' => "Authorization: Bearer $token"
]
];
$context = stream_context_create($options);
$response = file_get_contents($url, false, $context);
$data = json_decode($response, true);
print_r($data);
```
---
## 6⃣ Gestion des erreurs courants
### 401 Unauthorized
- **Cause**: Token invalide ou expiré, pas d'authentification
- **Solution**: Vérifier votre token, le renouveler si nécessaire
### 403 Forbidden
- **Cause**: Authentifié mais permissions insuffisantes
- **Solution**: Vérifier que vous avez les droits nécessaires
### 404 Not Found
- **Cause**: Ressource inexistante (ex: clé API inexistante)
- **Solution**: Vérifier l'ID de la ressource
---
## 7⃣ Sécurité
✅ **À FAIRE:**
- Garder votre token secret
- Utiliser HTTPS en production
- Créer de nouvelles clés régulièrement
- Révoquer les clés inutilisées
❌ **À NE PAS FAIRE:**
- Partager votre token
- Mettre votre token en version control (git)
- Utiliser le même token pour tout
- Ignorer les expirations
---
## 🆘 Besoin d'aide?
Consultez la **documentation complète** dans `DOCUMENTATION_API.md` pour:
- Architecture détaillée
- Configuration avancée
- Intégration dans différents frameworks
- FAQ et dépannage
---
**Prêt à utiliser l'API!** 🎉

159
document/INDEX.md Normal file
View file

@ -0,0 +1,159 @@
# 📚 Documentation API mcV3 - Index
## Bienvenue! 👋
Vous trouverez ici la documentation complète pour utiliser l'API de mcV3.
---
## 📖 Commencer par ici
### 🚀 [GUIDE_USER_RAPIDE_API.md](./GUIDE_USER_RAPIDE_API.md)
**Durée: 5 minutes**
Guide de démarrage rapide pour commencer immédiatement:
- ✅ Créer une clé API
- ✅ Accéder à l'API
- ✅ Exemples pratiques
- ✅ Gestion des erreurs courants
**Pour:** Utilisateurs pressés qui veulent tester rapidement
---
## 📚 [DOCUMENTATION_API.md](./DOCUMENTATION_API.md)
**Durée: 30 minutes pour lecture complète**
Documentation complète et détaillée couvrant:
- 🏗️ Architecture générale du système
- 🔐 Tous les détails d'authentification
- 📡 Documentation de chaque endpoint API
- 🔑 Gestion des clés API
- 👥 Rôles et permissions
- 📊 Codes HTTP et gestion d'erreurs
- 🔧 Intégrations (JS, Python, PHP)
- ⚙️ Configuration avancée
- ❓ FAQ et dépannage
**Pour:** Développeurs qui veulent comprendre le système en détail
---
## 🎯 Accès rapide par sujet
### Authentification
- [Guide rapide - Section 2](./GUIDE_USER_RAPIDE_API.md#2⃣-accéder-à-laapi)
- [Détaillé - Authentification](./DOCUMENTATION_API.md#authentification)
### Endpoints API
- [Guide rapide - Section 3](./GUIDE_USER_RAPIDE_API.md#3⃣-endpoints-disponibles)
- [Détaillé - Endpoints API](./DOCUMENTATION_API.md#endpoints-api)
### Gestion des clés
- [Guide rapide - Section 1](./GUIDE_USER_RAPIDE_API.md#1⃣-créer-une-clé-api)
- [Détaillé - Gestion des clés API](./DOCUMENTATION_API.md#gestion-des-clés-api)
### Rôles et permissions
- [Détaillé - Rôles et permissions](./DOCUMENTATION_API.md#rôles-et-permissions)
### Erreurs et dépannage
- [Guide rapide - Section 6](./GUIDE_USER_RAPIDE_API.md#6⃣-gestion-des-erreurs-courants)
- [Détaillé - Codes HTTP](./DOCUMENTATION_API.md#codes-http-et-erreurs)
- [Détaillé - FAQ](./DOCUMENTATION_API.md#faq-et-dépannage)
### Intégrations
- [Guide rapide - Section 5](./GUIDE_USER_RAPIDE_API.md#5⃣-utilisation-dans-votre-application)
- [Détaillé - Intégrations](./DOCUMENTATION_API.md#intégrations)
---
## 💡 Parcours d'apprentissage
### Niveau 1: Utilisateur final (15 min)
1. Lire [GUIDE_USER_RAPIDE_API.md](./GUIDE_USER_RAPIDE_API.md) sections 1-3
2. Créer une clé API
3. Tester un endpoint
### Niveau 2: Développeur frontend (1 heure)
1. Lire [GUIDE_USER_RAPIDE_API.md](./GUIDE_USER_RAPIDE_API.md) en entier
2. Lire [DOCUMENTATION_API.md](./DOCUMENTATION_API.md) sections Authentification + Endpoints
3. Implémenter l'intégration dans votre framework
4. Tester les erreurs courants
### Niveau 3: Développeur backend (2 heures)
1. Lire [DOCUMENTATION_API.md](./DOCUMENTATION_API.md) en entier
2. Comprendre l'architecture complète
3. Ajouter des endpoints personnalisés
4. Implémenter la gestion d'erreurs avancée
### Niveau 4: Administrateur (30 min)
1. Lire [DOCUMENTATION_API.md](./DOCUMENTATION_API.md) section Configuration avancée
2. Configurer HTTPS en production
3. Mettre en place le monitoring
---
## 🆘 Besoin d'aide rapide?
### "Je veux créer une clé API"
→ [GUIDE_USER_RAPIDE_API.md - Section 1](./GUIDE_USER_RAPIDE_API.md#1⃣-créer-une-clé-api)
### "Ça ne fonctionne pas"
→ [DOCUMENTATION_API.md - FAQ](./DOCUMENTATION_API.md#faq-et-dépannage)
### "Je veux intégrer l'API à mon app"
→ [DOCUMENTATION_API.md - Intégrations](./DOCUMENTATION_API.md#intégrations)
### "Qu'est-ce qu'une clé API?"
→ [DOCUMENTATION_API.md - Authentification](./DOCUMENTATION_API.md#mode-1-bearer-token-clé-api)
### "J'ai une erreur 401"
→ [DOCUMENTATION_API.md - Codes HTTP](./DOCUMENTATION_API.md#401-unauthorized)
### "Quels endpoints sont disponibles?"
→ [DOCUMENTATION_API.md - Endpoints API](./DOCUMENTATION_API.md#endpoints-api)
---
## 📋 Checklist - Avant d'aller en production
- [ ] Lire la documentation complète
- [ ] Créer une clé API de test
- [ ] Tester tous les endpoints
- [ ] Implémenter la gestion d'erreurs
- [ ] Activer HTTPS
- [ ] Configurer CORS si nécessaire
- [ ] Mettre en place le monitoring
- [ ] Documenter vos endpoints personnalisés
---
## 🚀 Commandes utiles
```bash
# Créer une clé API
php bin/console app:api-key:create pseudo_utilisateur "Nom"
# Lister les routes API
php bin/console debug:router | grep api
# Voir les logs
tail -f var/log/dev.log
# Vider le cache
php bin/console cache:clear
```
---
## 📞 Support
Pour toutes les questions:
1. Vérifier la [FAQ](./DOCUMENTATION_API.md#faq-et-dépannage)
2. Consulter la [section dépannage](./DOCUMENTATION_API.md#faq-et-dépannage)
3. Vérifier les [codes d'erreur](./DOCUMENTATION_API.md#codes-http-et-erreurs)
---
**Dernière mise à jour: 2026-04-03**

View file

@ -35,4 +35,17 @@ return [
'version' => '5.3.8',
'type' => 'css',
],
'tom-select' => [
'version' => '2.5.2',
],
'@orchidjs/sifter' => [
'version' => '1.1.0',
],
'@orchidjs/unicode-variants' => [
'version' => '1.1.2',
],
'tom-select/dist/css/tom-select.default.min.css' => [
'version' => '2.5.2',
'type' => 'css',
],
];

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260403163636 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" ADD bio TEXT NOT NULL default \'\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" DROP bio');
}
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260403164519 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE api_key (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, token VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, last_used_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, is_active BOOLEAN NOT NULL, user_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_C912ED9D5F37A13B ON api_key (token)');
$this->addSql('CREATE INDEX IDX_C912ED9DA76ED395 ON api_key (user_id)');
$this->addSql('ALTER TABLE api_key ADD CONSTRAINT FK_C912ED9DA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE "user" ALTER bio DROP DEFAULT');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE api_key DROP CONSTRAINT FK_C912ED9DA76ED395');
$this->addSql('DROP TABLE api_key');
$this->addSql('ALTER TABLE "user" ALTER bio SET DEFAULT \'\'');
}
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260405171429 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE mod_modpack DROP CONSTRAINT fk_9449c4f4949d6aeb');
$this->addSql('ALTER TABLE mod_modpack DROP CONSTRAINT fk_9449c4f4338e21cd');
$this->addSql('DROP TABLE mod_modpack');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE mod_modpack (mod_id INT NOT NULL, modpack_id INT NOT NULL, PRIMARY KEY (mod_id, modpack_id))');
$this->addSql('CREATE INDEX idx_9449c4f4338e21cd ON mod_modpack (mod_id)');
$this->addSql('CREATE INDEX idx_9449c4f4949d6aeb ON mod_modpack (modpack_id)');
$this->addSql('ALTER TABLE mod_modpack ADD CONSTRAINT fk_9449c4f4949d6aeb FOREIGN KEY (modpack_id) REFERENCES modpack (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE mod_modpack ADD CONSTRAINT fk_9449c4f4338e21cd FOREIGN KEY (mod_id) REFERENCES mod (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260406173517 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" RENAME COLUMN theme_sombre TO default_pwd');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" RENAME COLUMN default_pwd TO theme_sombre');
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260407125351 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE modpack ADD downloadable BOOLEAN NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE modpack DROP downloadable');
}
}

View file

@ -0,0 +1,79 @@
<?php
namespace App\Command;
use App\Entity\ApiKey;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:api-key:create',
description: 'Créer une clé API pour un utilisateur',
)]
class ApiKeyCreateCommand extends Command
{
public function __construct(
private UserRepository $userRepository,
private EntityManagerInterface $entityManager
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('pseudo', InputArgument::REQUIRED, 'Le pseudo de l\'utilisateur')
->addArgument('name', InputArgument::REQUIRED, 'Le nom de la clé API')
->addOption('expires', null, InputOption::VALUE_OPTIONAL, 'Date d\'expiration (format Y-m-d H:i:s)', null);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$pseudo = $input->getArgument('pseudo');
$name = $input->getArgument('name');
$expires = $input->getOption('expires');
$user = $this->userRepository->findOneBy(['pseudo' => $pseudo]);
if (!$user) {
$io->error("Utilisateur '$pseudo' non trouvé");
return Command::FAILURE;
}
$apiKey = new ApiKey();
$apiKey->setName($name);
$apiKey->setUser($user);
if ($expires) {
try {
$apiKey->setExpiresAt(new \DateTime($expires));
} catch (\Exception $e) {
$io->error("Format de date invalide: {$e->getMessage()}");
return Command::FAILURE;
}
}
$this->entityManager->persist($apiKey);
$this->entityManager->flush();
$io->success("Clé API créée avec succès!");
$io->text([
"Utilisateur: {$user->getPseudo()}",
"Nom: {$apiKey->getName()}",
"Token: {$apiKey->getToken()}",
"Créée à: {$apiKey->getCreatedAt()->format('Y-m-d H:i:s')}",
$expires ? "Expire à: {$apiKey->getExpiresAt()->format('Y-m-d H:i:s')}" : '',
]);
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:api:test',
description: 'Affiche des informations sur comment tester l\'API',
)]
class ApiTestCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('🚀 Test de l\'API avec authentification par clé');
$io->section('1⃣ Créer une clé API');
$io->text('Commande:');
$io->block('php bin/console app:api-key:create {pseudo} {nom_clé}', null, 'fg=black;bg=cyan', ' ');
$io->text('Exemple:');
$io->block('php bin/console app:api-key:create john "Mon API"', null, 'fg=black;bg=cyan', ' ');
$io->section('2⃣ Vérifier l\'état de l\'API (sans authentification)');
$io->text('Commande cURL:');
$io->block('curl http://localhost:8000/api/health', null, 'fg=black;bg=cyan', ' ');
$io->section('3⃣ Récupérer le profil (avec authentification)');
$io->text('Remplacer TOKEN par le token généré à l\'étape 1');
$io->block('curl -X GET http://localhost:8000/api/profile -H "Authorization: Bearer TOKEN"', null, 'fg=black;bg=cyan', ' ');
$io->section('4⃣ Autres endpoints');
$endpoints = [
'GET /api/api-keys' => 'Lister toutes vos clés API',
'POST /api/api-keys' => 'Créer une nouvelle clé (JSON body avec "name")',
'DELETE /api/api-keys/{id}' => 'Supprimer une clé',
'PATCH /api/api-keys/{id}/deactivate' => 'Désactiver une clé',
];
foreach ($endpoints as $route => $description) {
$io->text("<info>$route</info> - $description");
}
$io->section('📚 Documentation');
$io->text('Pour plus de détails, consultez:');
$io->listing([
'API_DOCUMENTATION.md - Documentation complète',
'GUIDE_API_RAPIDE.md - Guide de démarrage',
]);
$io->success('Prêt à utiliser votre API! 🎉');
return Command::SUCCESS;
}
}

View file

@ -2,17 +2,388 @@
namespace App\Controller;
use App\Entity\Constitue;
use App\Entity\Mod;
use App\Entity\Modpack;
use App\Entity\User;
use App\Form\Mod\ModType;
use App\Form\Modpack\ModpackType;
use App\Form\User\UserFormType;
use App\Service\UploaderService;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
#[Route('/administration')]
final class AdministrationController extends AbstractController
{
#[Route('/administration', name: 'app_administration')]
#[Route('', name: 'app_admin')]
public function index(): Response
{
return $this->render('administration/index.html.twig', [
'controller_name' => 'AdministrationController',
]);
}
/* Users */
#[route('/users', name: 'app_admin_users')]
public function admin_users(EntityManagerInterface $em): Response
{
$users = $em->getRepository(User::class)->findAll(array(), array('id' => 'ASC'));
return $this->render('administration/users/index.html.twig', [
'users' => $users,
]);
}
#[Route('/users/add', name: 'app_admin_users_add')]
public function admin_users_add(Request $request, EntityManagerInterface $em, #[currentUser] User $currentUser, UserPasswordHasherInterface $passwordHasher): Response
{
$user = new User();
$form = $this->createForm(UserFormType::class, $user);
$form ->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$plainPassword = $form->get('password')->getData();
if (empty($plainPassword)) {
$plainPassword = $user->getPseudo();
}
$hashedPassword = $passwordHasher->hashPassword($user, $plainPassword);
$user->setPassword($hashedPassword);
$user->setCreator($currentUser);
$user->addRole('ROLE_USER');
$em->persist($user);
$em->flush();
return $this->redirectToRoute('app_admin_users');
}
if ($form->isSubmitted() && !$form->isValid()) {
$errors = $form->getErrors(true);
foreach ($errors as $error) {
$this->addFlash('warning',$error->getMessage());
}
return $this->redirectToRoute('app_admin_users_add');
}
return $this->render('administration/users/edit.html.twig', [
'form' => $form->createView(),
]);
}
#[route('/users/{iduser}', name: 'app_admin_users_edit')]
public function admin_users_edit(Request $request, EntityManagerInterface $em,#[currentUser] User $currentUser,UserPasswordHasherInterface $passwordHasher, int $iduser): Response
{
$user = $em->getRepository(User::class)->find($iduser);
if (!$user) {
$this->addFlash('warning', 'Utilisateur non trouvé.');
return $this->redirectToRoute('app_admin_users');
}
if (!$currentUser->isAncestorOf($user)) {
$this->addFlash('danger', 'Vous n\'avez pas la permission de modifier cet utilisateur.');
return $this->redirectToRoute('app_admin_users');
}
$old_pwd = $user->getPseudo();
$form = $this->createForm(UserFormType::class, $user);
$form ->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$plainPassword = $form->get('password')->getData();
if (empty($plainPassword)) {
$plainPassword = $old_pwd;
}
else{
$hashedPassword = $passwordHasher->hashPassword($user, $plainPassword);
$user->setPassword($hashedPassword);
}
$this->addFlash('success', 'Utilisateur modifié avec succès.');
$em->persist($user);
$em->flush();
return $this->redirectToRoute('app_admin_users');
}
if ($form->isSubmitted() && !$form->isValid()) {
$errors = $form->getErrors(true);
foreach ($errors as $error) {
$this->addFlash('warning',$error->getMessage());
}
return $this->redirectToRoute('app_admin_users_edit', ['iduser' => $user->getId()]);
}
return $this->render('administration/users/edit.html.twig', [
'form' => $form->createView(),
]);
}
/* Mods & Modpack*/
#[Route('/mods', name: 'app_admin_mods')]
public function admin_mods(EntityManagerInterface $em): Response
{
$mods = $em->getRepository(Mod::class)->findBy(array(), array('nom' => 'ASC','version' => 'ASC'));
return $this->render('administration/mods/index.html.twig', [
'mods' => $mods,
]);
}
#[Route('/mods/add', name: 'app_admin_mods_add')]
public function admin_mods_add(Request $request, EntityManagerInterface $em, UploaderService $uploaderService): Response
{
$mod = new Mod();
$form = $this->createForm(ModType::class, $mod);
$form ->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var UploadedFile $photo */
$filemod = $form->get('uri')->getData();
if (!$filemod) {
$this->addFlash('danger', 'Fichier du mod manquant.');
return $this->redirectToRoute('app_admin_mods_add');
}
$filemodname = null;
try {
$filemodname = $uploaderService->upload($filemod, UploaderService::DIR_MOD,true);
}
catch (\Exception $exception){
$this->addFlash('danger','Le mod est déjà enregistré.');
return $this->redirectToRoute('app_admin_mods_add');
}
$mod->setUri($filemodname);
$em->persist($mod);
$em->flush();
$this->addFlash('success', 'Mod ajouté avec succès.');
return $this->redirectToRoute('app_admin_mods');
}
if ($form->isSubmitted() && !$form->isValid()) {
$errors = $form->getErrors(true);
foreach ($errors as $error) {
$errorMessage = $error->getMessage();
if ($errorMessage === 'Mod already exists'){
$this->addFlash('danger', 'Le mod existe déjà. Veuillez vérifier le nom et la version du mod que vous essayez d\'ajouter.');
return $this->redirectToRoute('app_admin_mods_add');
}
$this->addFlash('warning',$errorMessage);
}
return $this->redirectToRoute('app_admin_mods_add');
}
return $this->render('administration/mods/edit.html.twig', [
'form' => $form->createView(),
]);
}
#[route('/mods/{idmod}', name: 'app_admin_mods_edit')]
public function admin_mods_edit(Request $request, EntityManagerInterface $em, int $idmod): Response
{
$mod = $em->getRepository(Mod::class)->find($idmod);
if (!$mod) {
$this->addFlash('warning', 'Mod non trouvé.');
return $this->redirectToRoute('app_admin_mods');
}
$form = $this->createForm(ModType::class,$mod);
$form ->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($mod);
try {
$em->flush();
}
catch (\Exception $exception){
$this->addFlash('danger', 'Le mod existe déjà. Veuillez vérifier la version du mod que vous essayez de modifier.');
return $this->redirectToRoute('app_admin_mods_edit', ['idmod' => $idmod]);
}
$this->addFlash('success', 'Mod modifié avec succès.');
return $this->redirectToRoute('app_admin_mods');
}
return $this->render('administration/mods/edit.html.twig', [
'form' => $form->createView(),
]);
}
#[route('/mods/{idmod}/delete', name: 'app_admin_mods_delete')]
public function admin_mods_delete(Request $request, EntityManagerInterface $em, UploaderService $uploaderService,int $idmod): Response
{
$mod = $em->getRepository(Mod::class)->find($idmod);
if (!$mod) {
$this->addFlash('warning', 'Mod non trouvé.');
return $this->redirectToRoute('app_admin_mods');
}
if($mod->isUsed()){
$this->addFlash('danger', 'Impossible de supprimer le mod car il est utilisé dans un modpack. Veuillez d\'abord le retirer des modpacks qui l\'utilisent.');
return $this->redirectToRoute('app_admin_mods');
}
if($mod->isADependance()){
$this->addFlash('danger', 'Impossible de supprimer le mod car il est une dépendance d\'un autre mod. Veuillez d\'abord retirer cette dépendance des mods qui l\'utilisent.');
return $this->redirectToRoute('app_admin_mods');
}
$res = $uploaderService->delete($mod->getUri(), UploaderService::DIR_MOD);
if (empty($res)) {
$this->addFlash('warning', 'Fichier du mod introuvable ou déjà supprimé. Le mod a été supprimé de la base de données.');
}
if (!$res) {
$this->addFlash('danger', 'Impossible de supprimer le mod.');
return $this->redirectToRoute('app_admin_mods');
}
$em->remove($mod);
$em->flush();
$this->addFlash('success', 'Mod supprimé avec succès.');
return $this->redirectToRoute('app_admin_mods');
}
#[Route('/modpacks', name: 'app_admin_modpacks')]
public function admin_modpacks(EntityManagerInterface $em): Response
{
$modpacks = $em->getRepository(Modpack::class)->findBy(array(), array('version' => 'DESC'));
return $this->render('administration/modpacks/index.html.twig', [
'modpacks' => $modpacks,
]);
}
#[Route('/modpacks/add', name: 'app_admin_modpacks_add')]
public function admin_modpacks_add(Request $request, EntityManagerInterface $em): Response{
$modpack = new Modpack();
$form = $this->createForm(ModpackType::class, $modpack);
$form->handleRequest($request);
if ($form->isSubmitted()) {
$optionnelsMods = $form->get('mods_optionnels')->getData() ?? new ArrayCollection();
foreach ($form->get('mods')->getData() as $mod) {
$c = new Constitue();
$c->setModpack($modpack);
$c->setMod($mod);
$c->setOptionnel($optionnelsMods->contains($mod));
$em->persist($c);
}
$em->persist($modpack);
$em->flush();
return $this->redirectToRoute('app_admin_modpacks');
}
$this->addFlash('success', 'Modpack ajouté avec succès.');
return $this->render('administration/modpacks/edit.html.twig', [
'form' => $form->createView(),
]);
}
#[Route('/modpacks/{id}', name: 'app_admin_modpacks_edit')]
public function admin_modpacks_edit(Request $request, EntityManagerInterface $em, int $id): Response
{
$modpack = $em->getRepository(Modpack::class)->find($id);
if (!$modpack) {
$this->addFlash('warning', 'Modpack non trouvé.');
return $this->redirectToRoute('app_admin_modpacks');
}
$form = $this->createForm(ModpackType::class, $modpack);
// Pré-remplissage des champs mapped:false
$form->get('mods')->setData($modpack->getMods());
$form->get('mods_optionnels')->setData(
$modpack->getConstitues()
->filter(fn(Constitue $c) => $c->isOptionnel())
->map(fn(Constitue $c) => $c->getMod())
);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$optionnelsMods = $form->get('mods_optionnels')->getData() ?? new ArrayCollection();
$newMods = $form->get('mods')->getData()->toArray();
foreach ($modpack->getConstitues() as $constitue) {
$mod = $constitue->getMod();
if (!in_array($mod, $newMods, true)) {
// Mod retiré : suppression
$em->remove($constitue);
} else {
// Mod conservé : mise à jour de l'optionnel si besoin
$isOptionnel = $optionnelsMods->contains($mod);
if ($constitue->isOptionnel() !== $isOptionnel) {
$constitue->setOptionnel($isOptionnel);
}
// On le retire de $newMods pour ne pas le recréer
$newMods = array_filter($newMods, fn($m) => $m !== $mod);
}
}
// Mods restants dans $newMods : nouveaux à créer
foreach ($newMods as $mod) {
$c = new Constitue();
$c->setModpack($modpack);
$c->setMod($mod);
$c->setOptionnel($optionnelsMods->contains($mod));
$em->persist($c);
}
$em->persist($modpack);
$em->flush();
$this->addFlash('success', 'Modpack modifié avec succès.');
return $this->redirectToRoute('app_admin_modpacks');
}
return $this->render('administration/modpacks/edit.html.twig', [
'form' => $form->createView(),
]);
}
#[route('/modpacks/{id}/delete', name: 'app_admin_modpacks_delete')]
public function admin_modpacks_delete(Request $request, EntityManagerInterface $em, UploaderService $uploaderService,int $id): Response
{
$modpack = $em->getRepository(Modpack::class)->find($id);
if (!$modpack) {
$this->addFlash('warning', 'Modpack non trouvé.');
return $this->redirectToRoute('app_admin_modpacks');
}
if ($modpack->isDownloadable()) {
$this->addFlash('warning', 'Veuillez retirer ce modpack des versions téléchargeables pour le supprimer.');
return $this->redirectToRoute('app_admin_modpacks');
}
foreach ($modpack->getConstitues() as $constitue) {
$em->remove($constitue);
}
$em->remove($modpack);
$em->flush();
$this->addFlash('success', 'Modpack supprimé avec succès.');
return $this->redirectToRoute('app_admin_modpacks');
}
}

View file

@ -0,0 +1,156 @@
<?php
namespace App\Controller\Api\V1;
use App\Entity\ApiKey;
use App\Entity\User;
use App\Repository\ApiKeyRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class ApiKeyController extends AbstractController
{
#[Route('/api-keys', name: 'api_keys_list', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function listApiKeys(ApiKeyRepository $apiKeyRepository): JsonResponse
{
$user = $this->getUser();
// Les admins voient toutes les clés, les autres ne voient que les leurs
if ($this->isGranted('ROLE_ADMIN')) {
$keys = $apiKeyRepository->findAll();
} else {
$keys = $apiKeyRepository->findByUser($user);
}
$data = array_map(function (ApiKey $key) {
return [
'id' => $key->getId(),
'name' => $key->getName(),
'owner' => $key->getUser()->getPseudo(),
'token' => substr($key->getToken(), 0, 10) . '...', // Masquer le token complet
'createdAt' => $key->getCreatedAt()?->format('c'),
'lastUsedAt' => $key->getLastUsedAt()?->format('c'),
'expiresAt' => $key->getExpiresAt()?->format('c'),
'isActive' => $key->isActive(),
];
}, $keys);
return $this->json([
'apiKeys' => $data,
]);
}
#[Route('/api-keys', name: 'api_keys_create', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function createApiKey(
Request $request,
EntityManagerInterface $entityManager
): JsonResponse {
$data = json_decode($request->getContent(), true);
if (!isset($data['name'])) {
return $this->json(['error' => 'name is required'], Response::HTTP_BAD_REQUEST);
}
// Les admins peuvent créer des clés pour d'autres utilisateurs
$currentUser = $this->getUser();
if ($this->isGranted('ROLE_ADMIN') && isset($data['userId'])) {
// Un admin veut créer une clé pour un autre utilisateur
// Cette logique dépend de votre implémentation
$user = $entityManager->getRepository(User::class)->find($data['userId']);
if (!$user) {
return $this->json(['error' => 'User not found'], Response::HTTP_NOT_FOUND);
}
} else {
$user = $currentUser;
}
$apiKey = new ApiKey();
$apiKey->setName($data['name']);
$apiKey->setUser($user);
if (isset($data['expiresAt'])) {
$apiKey->setExpiresAt(new \DateTime($data['expiresAt']));
}
$entityManager->persist($apiKey);
$entityManager->flush();
return $this->json([
'id' => $apiKey->getId(),
'name' => $apiKey->getName(),
'token' => $apiKey->getToken(),
'createdAt' => $apiKey->getCreatedAt()?->format('c'),
'message' => 'Copier le token en lieu sûr',
], Response::HTTP_CREATED);
}
#[Route('/api-keys/{id}', name: 'api_keys_get', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function getApiKey(
ApiKey $apiKey,
EntityManagerInterface $entityManager
): JsonResponse {
$user = $this->getUser();
// Seuls les propriétaires peuvent voir les détails d'une clé
if ($apiKey->getUser() !== $user) {
return $this->json(['error' => 'Unauthorized'], Response::HTTP_FORBIDDEN);
}
return $this->json([
'id' => $apiKey->getId(),
'name' => $apiKey->getName(),
'token' => $apiKey->getToken(),
'createdAt' => $apiKey->getCreatedAt()?->format('c'),
'lastUsedAt' => $apiKey->getLastUsedAt()?->format('c'),
'expiresAt' => $apiKey->getExpiresAt()?->format('c'),
'isActive' => $apiKey->isActive(),
'message' => 'Copier le token en lieu sûr',
]);
}
#[Route('/api-keys/{id}', name: 'api_keys_delete', methods: ['DELETE'])]
#[IsGranted('ROLE_USER')]
public function deleteApiKey(
ApiKey $apiKey,
EntityManagerInterface $entityManager
): JsonResponse {
$user = $this->getUser();
// Les admins peuvent supprimer n'importe quelle clé, les autres seulement les leurs
if (!$this->isGranted('ROLE_ADMIN') && $apiKey->getUser() !== $user) {
return $this->json(['error' => 'Unauthorized'], Response::HTTP_FORBIDDEN);
}
$entityManager->remove($apiKey);
$entityManager->flush();
return $this->json(['message' => 'API key deleted']);
}
#[Route('/api-keys/{id}/deactivate', name: 'api_keys_deactivate', methods: ['PATCH'])]
#[IsGranted('ROLE_USER')]
public function deactivateApiKey(
ApiKey $apiKey,
EntityManagerInterface $entityManager
): JsonResponse {
$user = $this->getUser();
// Les admins peuvent désactiver n'importe quelle clé, les autres seulement les leurs
if (!$this->isGranted('ROLE_ADMIN') && $apiKey->getUser() !== $user) {
return $this->json(['error' => 'Unauthorized'], Response::HTTP_FORBIDDEN);
}
$apiKey->setIsActive(false);
$entityManager->flush();
return $this->json(['message' => 'API key deactivated']);
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Controller\Api\V1;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class StateController extends AbstractController
{
#[Route('/health', name: 'app_api_v1_health', methods: ['GET'])]
public function health(): JsonResponse
{
return $this->json([
'status' => 'ok',
'timestamp' => date('c'),
]);
}
#[Route('/profile', name: 'app_api_v1_profile', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function profile(): JsonResponse
{
$user = $this->getUser();
return $this->json([
'id' => $user->getId(),
'pseudo' => $user->getPseudo(),
'roles' => $user->getRoles(),
'authenticatedVia' => $this->getAuthenticationMode(),
]);
}
/**
* Déterminer le mode d'authentification utilisé
*/
private function getAuthenticationMode(): string
{
$token = $this->container->get('security.token_storage')->getToken();
if (!$token) {
return 'none';
}
$tokenClass = basename(str_replace('\\', '/', $token::class));
if (strpos($tokenClass, 'AccessToken') !== false) {
return 'api_key';
}
return 'session';
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace App\Controller;
use App\Entity\Mod;
use App\Entity\Modpack;
use App\Form\Modpack\ModpackDownloadType;
use App\Service\ModpackZipService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/modpacks')]
final class ModpackController extends AbstractController
{
#[Route('', name: 'app_modpack')]
public function index(Request $request, EntityManagerInterface $em): Response
{
$modpacks = $em->getRepository(Modpack::class)->findBy(
['downloadable' => true],
['version' => 'DESC']
);
$id = $request->query->get('v');
$modpack = $id
? $em->getRepository(Modpack::class)->find($id)
: ($modpacks[0] ?? null);
// Sécurité : le modpack demandé doit être downloadable
if (!$modpack || !$modpack->isDownloadable()) {
$modpack = $modpacks[0] ?? null;
}
$form = $this->createForm(ModpackDownloadType::class, $modpack);
return $this->render('modpack/index.html.twig', [
'modpack' => $modpack,
'modpacks' => $modpacks,
'form' => $form,
]);
}
#[Route('/{id}/download', name: 'app_modpack_download')]
public function download(
Modpack $modpack,
Request $request,
EntityManagerInterface $em,
ModpackZipService $modpackZipService
): Response {
// Mods obligatoires
$mods = $modpack->getConstitues()
->filter(fn($c) => !$c->isOptionnel())
->map(fn($c) => $c->getMod())
->getValues();
// Mods optionnels depuis ?opt=1+2+3
$optParam = $request->query->get('opt', '');
$optIds = array_filter(explode(' ', $optParam)); // "+" décodé en espace par PHP
if (!empty($optIds)) {
// Sécurité : on vérifie que les IDs appartiennent bien au modpack
$allowedOptIds = $modpack->getConstitues()
->filter(fn($c) => $c->isOptionnel())
->map(fn($c) => (string) $c->getMod()->getId())
->getValues();
$safeIds = array_intersect($optIds, $allowedOptIds);
$modsOptionnels = $em->getRepository(Mod::class)->findBy(['id' => $safeIds]);
$mods = array_merge($mods, $modsOptionnels);
}
$zipPath = $modpackZipService->createZip($modpack, $mods);
$response = new BinaryFileResponse($zipPath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
'modpack-' . $modpack->getVersion() . '.zip'
);
$response->deleteFileAfterSend(true);
return $response;
}
}

View file

@ -27,7 +27,7 @@ class SecurityController extends AbstractController
$user->setRoles(["ROLE_ADMIN","ROLE_USER"]);
$user->setPseudo("admin");
$user->setPassword('$2y$13$CVJ/Hm29HJ3ehPrqrtMl8Oya55d/ZXwlhfL6D2TsWI238bHAqtCrS');
$user->setThemeSombre(false);
$user->setDefaultpwd(true);
$em->persist($user);
$em->flush();
}
@ -35,6 +35,7 @@ class SecurityController extends AbstractController
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();

View file

@ -0,0 +1,98 @@
<?php
namespace App\Controller;
use App\Entity\User;
use App\Form\User\Settings\ProfileType;
use App\Form\User\Settings\SecurityType;
use App\Service\UploaderService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
#[Route('/settings')]
final class SettingsController extends AbstractController
{
#[Route('', name: 'app_settings')]
public function index(): Response
{
return $this->redirectToRoute('app_settings_profile');
}
#[Route('/profile', name: 'app_settings_profile')]
public function profile(Request $request, EntityManagerInterface $em,#[currentUser] User $user, UploaderService $uploaderService): Response
{
$form = $this->createForm(ProfileType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var UploadedFile $photo */
$photo = $form->get('profile_picture_file')->getData();
$nomPhoto = null;
if ($photo) {
$nomPhoto = $uploaderService->upload($photo, UploaderService::DIR_PP);
}
else if ($form->get('uri_pp')->getData()) {
$nomPhoto = $form->get('uri_pp')->getData();
}
if ($nomPhoto) {
if (!$user->hasURLpp() && $user->getUriPp()){
// Supprimer l'ancienne photo si elle existe et n'est pas une URL
$uploaderService->delete($user->getUriPp(), UploaderService::DIR_PP);
}
$user->setUriPp($nomPhoto);
}
$em->persist($user);
$em->flush();
$this->addFlash('success', 'Profil mis à jour avec succès.');
return $this->redirectToRoute('app_settings_profile');
}
return $this->render('settings/profile.html.twig', [
'form' => $form->createView(),
]);
}
#[Route('/security', name: 'app_settings_security')]
public function security(Request $request, EntityManagerInterface $em,#[currentUser] User $user,UserPasswordHasherInterface $passwordHasher): Response
{
$form = $this->createForm(SecurityType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (!password_verify($form->get('current_password')->getData(), $user->getPassword())) {
$this->addFlash('danger', 'Mot de passe actuel incorrect.');
return $this->redirectToRoute('app_settings_security');
}
if ($form->get('new_password')->getData() !== $form->get('new_password_confirmation')->getData()) {
$this->addFlash('danger', 'Les mots de passe ne correspondent pas.');
return $this->redirectToRoute('app_settings_security');
}
if ($form->get('new_password')->getData() === $form->get('current_password')->getData()){
$this->addFlash('danger', 'Le nouveau mot de passe doit être différent de l\'ancien.');
return $this->redirectToRoute('app_settings_security');
}
$hash = $passwordHasher->hashPassword($user, $form->get('new_password')->getData());
$user->setPassword($hash);
$user->setDefaultPwd(false);
$em->persist($user);
$em->flush();
$this->addFlash("success","Mot de passe mis à jour avec succès.");
return $this->redirectToRoute('app_settings_security');
}
return $this->render('settings/security.html.twig', [
'form' => $form,
]);
}
}

View file

@ -11,8 +11,7 @@ final class VitrineController extends AbstractController
#[Route('/', name: 'app_accueil')]
public function index(): Response
{
return $this->render('vitrine/index.html.twig', [
'controller_name' => 'VitrineController',
]);
return $this->render('vitrine/index.html.twig', []);
}
}

151
src/Entity/ApiKey.php Normal file
View file

@ -0,0 +1,151 @@
<?php
namespace App\Entity;
use App\Repository\ApiKeyRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ApiKeyRepository::class)]
class ApiKey
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, unique: true)]
private ?string $token = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?User $user = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $createdAt = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $lastUsedAt = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $expiresAt = null;
#[ORM\Column]
private bool $isActive = true;
public function __construct()
{
$this->createdAt = new \DateTime();
// Générer un token unique (64 caractères aléatoires en hex)
$this->token = bin2hex(random_bytes(32));
}
public function getId(): ?int
{
return $this->id;
}
public function getToken(): ?string
{
return $this->token;
}
public function setToken(string $token): static
{
$this->token = $token;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getLastUsedAt(): ?\DateTimeInterface
{
return $this->lastUsedAt;
}
public function setLastUsedAt(?\DateTimeInterface $lastUsedAt): static
{
$this->lastUsedAt = $lastUsedAt;
return $this;
}
public function getExpiresAt(): ?\DateTimeInterface
{
return $this->expiresAt;
}
public function setExpiresAt(?\DateTimeInterface $expiresAt): static
{
$this->expiresAt = $expiresAt;
return $this;
}
public function isActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): static
{
$this->isActive = $isActive;
return $this;
}
/**
* Vérifie si la clé API est valide (active et non expirée)
*/
public function isValid(): bool
{
if (!$this->isActive) {
return false;
}
if ($this->expiresAt && $this->expiresAt < new \DateTime()) {
return false;
}
return true;
}
}

View file

@ -10,7 +10,7 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
#[ORM\Entity(repositoryClass: ModRepository::class)]
#[ORM\UniqueConstraint(name: 'mod_unique', columns: ['nom', 'version'])]
#[UniqueEntity(fields: ['nom', 'version'], message: 'relation already exists')]
#[UniqueEntity(fields: ['nom', 'version'], message: 'Mod already exists')]
class Mod
{
#[ORM\Id]
@ -35,27 +35,22 @@ class Mod
/**
* @var Collection<int, self>
* .
*/
#[ORM\ManyToMany(targetEntity: self::class, mappedBy: 'dependances')]
private Collection $soumis;
/**
* @var Collection<int, Modpack>
*/
#[ORM\ManyToMany(targetEntity: Modpack::class, inversedBy: 'mods')]
private Collection $constitue;
/**
* @var Collection<int, Constitue>
*/
#[ORM\OneToMany(targetEntity: Constitue::class, mappedBy: 'mod')]
private Collection $constitues;
public function __construct()
{
$this->soumis = new ArrayCollection();
$this->dependances = new ArrayCollection();
$this->constitue = new ArrayCollection();
$this->constitues = new ArrayCollection();
}
@ -180,4 +175,13 @@ class Mod
return $this;
}
public function isUsed(): bool{
return !$this->constitues->isEmpty();
}
public function isADependance(): bool{
return !$this->soumis->isEmpty();
}
}

View file

@ -24,6 +24,10 @@ class Modpack
#[ORM\OneToMany(targetEntity: Constitue::class, mappedBy: 'modpack')]
private Collection $constitues;
#[ORM\Column]
private bool $downloadable = false;
public function __construct()
{
$this->constitues = new ArrayCollection();
@ -75,4 +79,42 @@ class Modpack
return $this;
}
public function getConstituteByMod(Mod $mod): ?Constitue
{
foreach ($this->constitues as $constitue) {
if ($constitue->getMod() === $mod) {
return $constitue;
}
}
return null;
}
public function getMods(): Collection
{
$mods = new ArrayCollection();
foreach ($this->constitues as $constitue) {
$mods->add($constitue->getMod());
}
return $mods;
}
public function isDownloadable(): bool
{
return $this->downloadable;
}
public function setDownloadable(bool $downloadable): static
{
$this->downloadable = $downloadable;
return $this;
}
public function numberOfMods(): int
{
return count($this->constitues);
}
}

View file

@ -5,6 +5,7 @@ namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
@ -38,7 +39,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private ?string $uri_pp = null;
#[ORM\Column]
private ?bool $theme_sombre = null;
private ?bool $default_pwd = true;
#[ORM\OneToOne(inversedBy: 'owner', cascade: ['persist', 'remove'])]
private ?Joueur $joueur = null;
@ -58,6 +59,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\OneToMany(targetEntity: Publication::class, mappedBy: 'author')]
private Collection $publications;
#[ORM\Column(type: Types::TEXT, nullable: false)]
private ?string $bio = "";
public function __construct()
{
$this->users = new ArrayCollection();
@ -103,6 +107,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return array_unique($roles);
}
public function hasRole(string $role): bool
{
return in_array($role, $this->getRoles(), true);
}
/**
* @param list<string> $roles
*/
@ -113,6 +122,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function addRole(string $role): static
{
$this->roles[] = $role;
return $this;
}
/**
* @see PasswordAuthenticatedUserInterface
*/
@ -157,14 +172,14 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function isThemeSombre(): ?bool
public function hasDefaultpwd(): bool
{
return $this->theme_sombre;
return $this->default_pwd;
}
public function setThemeSombre(bool $theme_sombre): static
public function setDefaultpwd(bool $default_pwd): static
{
$this->theme_sombre = $theme_sombre;
$this->default_pwd = $default_pwd;
return $this;
}
@ -252,4 +267,47 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function isAdmin(): ?bool
{
return $this->hasRole("ROLE_ADMIN");
}
public function isAncestorOf(User $user): bool
{
$sommet = $user->getCreator();
while ($sommet and $sommet!==$this) {
$sommet = $sommet->getCreator();
}
return $sommet===$this;
}
public function getBio(): ?string
{
return $this->bio;
}
public function setBio(string $bio): static
{
$this->bio = $bio;
return $this;
}
public function hasURLpp(): bool
{
$uri_pp = $this->uri_pp;
if (!$this->uri_pp) return false;
return str_starts_with($uri_pp, 'https');
}
public function getGlobalURLpp(): ?string{
if ($this->hasURLpp()) {
return $this->uri_pp;
}
if ($this->getUriPp()) {
return '/uploads/pp/' . $this->getUriPp();
}
return null;
}
}

View file

@ -0,0 +1,106 @@
<?php
namespace App\EventListener;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Convertit les exceptions de sécurité et redirections en réponses JSON pour l'API
*/
class ApiExceptionListener
{
/**
* IMPORTANT: Exécuté TRÈS tôt pour forcer le chargement de la session
*/
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
// Seulement pour /api
if (!str_starts_with($request->getPathInfo(), '/api')) {
return;
}
// S'assurer que la session est chargée et disponible
// Cela permet au firewall /api d'accéder à la session du context partagé
try {
if ($request->hasSession()) {
$session = $request->getSession();
// Forcer le chargement/démarrage de la session
if (!$session->isStarted()) {
$session->start();
}
}
} catch (\Exception $e) {
// Ignorer les erreurs de session
}
}
public function onKernelException(ExceptionEvent $event): void
{
$request = $event->getRequest();
$exception = $event->getThrowable();
// Vérifier si c'est une route /api
if (!str_starts_with($request->getPathInfo(), '/api')) {
return;
}
$response = null;
if ($exception instanceof AuthenticationException) {
// Non authentifié
$response = new JsonResponse(
[
'error' => 'Unauthorized',
'message' => 'Authentication required',
],
401
);
} elseif ($exception instanceof AccessDeniedException) {
// Authentifié mais pas les droits
$response = new JsonResponse(
[
'error' => 'Forbidden',
'message' => 'You do not have permission to access this resource',
],
403
);
}
if ($response !== null) {
$event->setResponse($response);
}
}
/**
* Intercepter les réponses de redirection sur les routes /api
* et les convertir en 401 JSON
*/
public function onKernelResponse(ResponseEvent $event): void
{
$request = $event->getRequest();
$response = $event->getResponse();
// Vérifier si c'est une route /api ET une redirection (302, 301, 303, 307, 308)
if (str_starts_with($request->getPathInfo(), '/api') &&
in_array($response->getStatusCode(), [301, 302, 303, 307, 308])) {
// Remplacer la redirection par une réponse 401 JSON
$jsonResponse = new JsonResponse(
[
'error' => 'Unauthorized',
'message' => 'Authentication required. Please provide a valid API token or be logged in.',
],
401
);
$event->setResponse($jsonResponse);
}
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\EventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
/**
* Force le chargement de la session pour les routes /api
* Exécuté très tôt dans le cycle de requête
*/
class ApiForceSessionListener
{
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
// Seulement pour les routes /api
if (!str_starts_with($request->getPathInfo(), '/api')) {
return;
}
// IMPORTANT: Forcer l'initialisation de la session
// Cela garantit que la session existante sera chargée
// avant que le firewall n'essaie de faire son travail
try {
$session = $request->getSession();
// L'accès à la session force son chargement/initialisation
if ($session->has('_sf2_attributes')) {
// Session existe et contient des données
}
} catch (\Exception $e) {
// Ignorer les erreurs de session
}
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Exception\InsufficientAuthenticationException;
class AccessDeniedSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
KernelEvents::EXCEPTION => ['onKernelException', 10],
];
}
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
// Intercepter les exceptions d'authentification insuffisante (utilisateur non connecté)
if ($exception instanceof InsufficientAuthenticationException) {
// Rediriger vers la page d'accueil au lieu de /login
$event->setResponse(new RedirectResponse('/'));
return;
}
// Intercepter les exceptions d'accès refusé (utilisateur connecté mais sans rôle)
if ($exception instanceof AccessDeniedException) {
// Rediriger vers la page d'accueil
$event->setResponse(new RedirectResponse('/'));
}
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Security\Http\Event\LogoutEvent;
class LogoutSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
LogoutEvent::class => 'onLogout',
];
}
public function onLogout(LogoutEvent $event): void
{
// Récupérer l'URL referer
$referer = $event->getRequest()->headers->get('referer');
// Vérifier que le referer n'est pas une URL de logout ou login
if ($referer && !str_contains($referer, '/logout') && !str_contains($referer, '/login')) {
$event->setResponse(new RedirectResponse($referer));
} else {
// Sinon rediriger vers la page d'accueil
$event->setResponse(new RedirectResponse('/'));
}
}
}

91
src/Form/Mod/ModType.php Normal file
View file

@ -0,0 +1,91 @@
<?php
namespace App\Form\Mod;
use App\Entity\Mod;
use App\Repository\ModRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class ModType extends AbstractType
{
public function __construct(private readonly ModRepository $modRepository) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$placeholderModNames = [
'Create',
'Ars Nouveau',
'Oculus',
'JEI',
];
$isEdit = $options['data']?->getId() !== null;
$mods = [];
if ($isEdit) {
$mods = $this->modRepository->findAvailableDependances($builder->getData());
}
$builder
->add('nom', TextType::class, [
'disabled' => $isEdit,
'label' => 'Nom du mod',
'required' => true,
'attr' => [
'placeholder' => 'Ex: '.$placeholderModNames[array_rand($placeholderModNames)],
],
])
->add('version', TextType::class, [
'label' => 'Version du mod',
'required' => true,
])
->add('uri', FileType::class, [
'disabled' => $isEdit,
'mapped' => false,
'label' => 'URL du mod',
'constraints' => [
new Assert\File([
'maxSize' => '128M',
'maxSizeMessage' => 'Le fichier est trop volumineux ({{ size }} {{ suffix }}). La taille maximale autorisée est {{ limit }} {{ suffix }}.',
'mimeTypes' => [
'application/java-archive',
'application/x-java-archive',
'application/x-jar',
'application/zip', // JAR = ZIP, souvent détecté ainsi
'application/x-zip-compressed',
],
'mimeTypesMessage' => 'Uniquement des fichiers JAR sont autorisés.',
]),
],
'required' => !$isEdit, // Obligatoire uniquement lors de la création
'attr' => [
'accept' => '.jar',
]
])
->add('dependances', EntityType::class, [
'class' => Mod::class,
'choice_label' => function (Mod $mod) {
return $mod->getNom() . ' - ' . $mod->getVersion();},
'multiple' => true,
'required' => false,
'choices' => $mods,
'disabled' => !$isEdit,
'attr' => [
'id' => 'dependances',
'data-placeholder'=> $isEdit? 'Rechercher une dépendance...' : 'Ajouter une dependance après la création du mod',
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Mod::class,
]);
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Form\Modpack;
use App\Entity\Constitue;
use App\Entity\Mod;
use App\Entity\Modpack;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ModpackDownloadType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
/** @var Modpack $modpack */
$modpack = $options['data'];
$optionnels = $modpack->getConstitues()
->filter(fn(Constitue $c) => $c->isOptionnel())
->map(fn(Constitue $c) => $c->getMod())
->getValues();
$builder
->add('mods_optionnels', EntityType::class, [
'mapped' => false,
'class' => Mod::class,
'choice_label' => fn(Mod $mod) => $mod->getNom() . ' - ' . $mod->getVersion(),
'choices' => $optionnels,
'multiple' => true,
'expanded' => true,
'label' => 'Mods optionnels',
'required' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Modpack::class,
]);
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Form\Modpack;
use App\Entity\Mod;
use App\Entity\Modpack;
use App\Repository\ModRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ModpackType extends AbstractType
{
public function __construct(private readonly ModRepository $modRepository) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$isEdit = $options['data']?->getId() !== null;
$mods = $this->modRepository->findBy([], ['nom' => 'ASC', 'version' => 'ASC']);
$builder
->add('version',TextType::class,[
'label' => 'Version',
])
->add('downloadable', CheckboxType::class, [
'label' => 'Téléchargeable',
'required' => false,
'disabled' => !$isEdit,
])
->add('mods', EntityType::class, [
'mapped' => false,
'class' => Mod::class,
'choice_label' => function (Mod $mod) {
return $mod->getNom() . ' - ' . $mod->getVersion();},
'choices' => $mods,
'multiple' => true,
'expanded' => true,
'label' => 'Mods',
'required' => false,
])
->add('mods_optionnels', EntityType::class, [
'mapped' => false,
'class' => Mod::class,
'choice_label' => fn(Mod $mod) => $mod->getNom() . ' - ' . $mod->getVersion(),
'choices' => $mods,
'multiple' => true,
'expanded' => true,
'label' => false,
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Modpack::class,
]);
}
}

View file

@ -0,0 +1,82 @@
<?php
namespace App\Form\User\Settings;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class ProfileType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('pseudo', TextType::class, [
'disabled' => true,
'label' => 'Pseudo',
/*'constraints' => [
new Assert\NotBlank(),
new Assert\Length(min: 3, max: 30),
new Assert\Regex(
pattern: '/^[\w\-\.]+$/u',
message: 'Le pseudo ne peut contenir que des lettres, chiffres, tirets et points.'
),
],*/
])
->add('bio', TextareaType::class, [
'label' => 'Bio',
'required' => false,
'empty_data' => '',
'constraints' => [
new Assert\Length(max: 500),
],
'attr' => ['maxlength' => 500, 'rows' => 4],
])
// url
->add('uri_pp', UrlType::class, [
'label' => 'URL de la photo de profil',
'mapped' => false,
'required' => false,
'default_protocol' => 'https',
'empty_data' => '',
'constraints' => [
new Assert\Url(
protocols: ['https'],
message: 'URL incorrecte',
),
new Assert\Regex(
pattern: '/^https:\/\/([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}(\/.*)?$/',
message: 'URL incorrecte (ex : https://monsite.com)',
),
],
])
// fichier
->add('profile_picture_file', FileType::class, [
'label' => 'Ou uploader une image',
'mapped' => false,
'required' => false,
'constraints' => [
new Assert\Image([
'maxSize' => '2M',
'mimeTypes' => ['image/jpeg', 'image/png', 'image/webp'],
'mimeTypesMessage' => 'Format accepté : JPG, PNG ou WebP.',
]),
],
'attr' => ['accept' => 'image/jpeg, image/png, image/webp'],
'help' => 'PNG, JPG ou WEBP — 2 Mo maximum'
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => \App\Entity\User::class, // ← manquant
]);
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Form\User\Settings;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class SecurityType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('current_password', PasswordType::class, [
'mapped' => false,
"label" => "Mot de passe actuel",
"required" => true,
])
->add('new_password', PasswordType::class, [
'mapped' => false,
"label" => "Nouveau mot de passe",
"required" => true,
"constraints" => [
new Assert\Length([
'min' => 8,
'minMessage' => 'Votre mot de passe doit comporter au moins {{ limit }} caractères.',
]),
new Assert\Regex([
'pattern' => '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d@$!%*?&]{8,}$/',
'message' => 'Le mot de passe doit contenir au moins 8 caractères, une majuscule, une minuscule et un chiffre.',
]),
new Assert\NotBlank([
'message' => 'Le mot de passe ne peut pas être vide.',
]),
]
])
->add('new_password_confirmation', PasswordType::class, [
'mapped' => false,
'label' => 'Confirmer le nouveau mot de passe',
'required' => true,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
// Configure your form options here
]);
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace App\Form\User;
use App\Entity\Joueur;
use App\Entity\User;
use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
class UserFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$isEdit = $options['data']?->getId() !== null;
$builder
->add('pseudo', TextType::class ,[
"label" => "Pseudo",
"required" => true,
"disabled" => $isEdit,
"attr" => [
"placeholder" => "Pseudo",
]
])
->add('roles', ChoiceType::class, [
"label" => "Roles",
'choices' => [
'Admin' => 'ROLE_ADMIN',
'Joueur' => 'ROLE_JOUEUR',
'Visiteur' => 'ROLE_VISITEUR',
],
'expanded' => true,
'multiple' => true,
'required' => false,
'constraints' => [
new Callback(function (array $roles, ExecutionContextInterface $context) {
$mutuallyExclusive = ['ROLE_B', 'ROLE_C'];
// Intersection entre les rôles sélectionnés et les rôles exclusifs
$found = array_intersect($roles, $mutuallyExclusive);
if (count($found) > 1) {
$context
->buildViolation('Les rôles B et C ne peuvent pas être attribués ensemble.')
->addViolation();
}
}),
],
])
->add('password', PasswordType::class, [
"label" => "Mot de passe",
'mapped' => false,
"required" => false,
'attr' => [
'autocomplete' => 'new-password',
"placeholder" => "Mot de passe, laissez vide pour mot de passe par defaut",
]
])
->add('joueur', EntityType::class, [
"label" => "Joueur associé",
'class' => Joueur::class,
'choice_label' => 'pseudo',
"required" => false,
"multiple" => false,
"query_builder" => function (EntityRepository $er) use ($options) {
$qb = $er->createQueryBuilder('j')
->leftJoin('j.owner', 'u')
->where('u.id IS NULL');
// En mode édition, inclure le joueur déjà associé
$currentJoueur = $options['data']?->getJoueur();
if ($currentJoueur) {
$qb->orWhere('j.id = :current')
->setParameter('current', $currentJoueur->getId());
}
return $qb->orderBy('j.pseudo', 'ASC');
}
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Repository;
use App\Entity\ApiKey;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ApiKey>
*/
class ApiKeyRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ApiKey::class);
}
/**
* Trouve une clé API valide par token
*/
public function findValidByToken(string $token): ?ApiKey
{
$apiKey = $this->findOneBy(['token' => $token]);
if (!$apiKey || !$apiKey->isValid()) {
return null;
}
return $apiKey;
}
/**
* Trouve toutes les clés API d'un utilisateur
*/
public function findByUser($user): array
{
return $this->findBy(['user' => $user], ['createdAt' => 'DESC']);
}
/**
* Supprime les clés API expirées
*/
public function deleteExpiredKeys(): int
{
return $this->createQueryBuilder('ak')
->delete()
->where('ak.expiresAt < :now')
->setParameter('now', new \DateTime())
->getQuery()
->execute();
}
}

View file

@ -16,6 +16,52 @@ class ModRepository extends ServiceEntityRepository
parent::__construct($registry, Mod::class);
}
/**
* Retourne tous les mods qui ne sont pas une dépendance (directe ou indirecte)
* du mod passé en paramètre, et qui n'est pas le mod lui-même.
*/
public function findAvailableDependances(Mod $mod): array
{
$excludedIds = $this->collectDependencyIds($mod, []);
$excludedIds[] = $mod->getId();
$qb = $this->createQueryBuilder('m');
if (!empty($excludedIds)) {
$qb->where('m.id NOT IN (:excludedIds)')
->setParameter('excludedIds', $excludedIds);
}
return $qb->getQuery()->getResult();
}
/**
* Collecte récursivement les IDs de toutes les dépendances d'un mod.
*
* @param int[] $visited IDs déjà visités (évite les cycles infinis)
* @return int[]
*/
private function collectDependencyIds(Mod $mod, array $visited): array
{
$ids = [];
foreach ($mod->getDependances() as $dependance) {
$depId = $dependance->getId();
// Evite les boucles infinies en cas de dépendances cycliques
if (in_array($depId, $visited, true)) {
continue;
}
$visited[] = $depId;
$ids[] = $depId;
$ids = array_merge($ids, $this->collectDependencyIds($dependance, $visited));
}
return $ids;
}
// /**
// * @return Mod[] Returns an array of Mod objects
// */

View file

@ -0,0 +1,19 @@
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface;
class AccessDeniedHandler implements AccessDeniedHandlerInterface
{
public function handle(Request $request, AccessDeniedException $accessDeniedException): Response
{
// Rediriger vers la page d'accueil
return new RedirectResponse('/');
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface;
/**
* Gestionnaire d'accès refusé personnalisé pour l'API
* Retourne des réponses JSON au lieu de rediriger
*/
class ApiAccessDeniedHandler implements AccessDeniedHandlerInterface
{
public function handle(Request $request, AccessDeniedException $accessDeniedException): JsonResponse
{
return new JsonResponse(
[
'error' => 'Forbidden',
'message' => 'You do not have permission to access this resource',
],
403 // HTTP 403 Forbidden
);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
/**
* Point d'entrée personnalisé pour l'API
* Retourne des réponses JSON au lieu de rediriger
*/
class ApiAuthenticationEntryPoint implements AuthenticationEntryPointInterface
{
public function start(Request $request, AuthenticationException $authException = null): JsonResponse
{
return new JsonResponse(
[
'error' => 'Unauthorized',
'message' => 'Authentication required',
],
401 // HTTP 401 Unauthorized
);
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
/**
* Authenticateur pour les tokens API (Bearer tokens)
* Utilisé uniquement si un header Authorization est présent
* Laisse la session tranquille si pas de token
*/
class ApiBearerTokenAuthenticator extends AbstractAuthenticator
{
public function __construct(private ApiTokenHandler $tokenHandler)
{
}
public function supports(Request $request): ?bool
{
// Seulement si c'est un Bearer token
return $request->headers->has('Authorization') &&
str_starts_with($request->headers->get('Authorization', ''), 'Bearer ');
}
public function authenticate(Request $request): Passport
{
$authHeader = $request->headers->get('Authorization');
$token = substr($authHeader, 7); // Enlever "Bearer "
// Valider le token
$userBadge = $this->tokenHandler->getUserBadgeFrom($token);
return new SelfValidatingPassport($userBadge);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?JsonResponse
{
// Laisser la requête continuer
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): JsonResponse
{
return new JsonResponse(
[
'error' => 'Unauthorized',
'message' => 'Invalid or expired API token',
],
401
);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Security;
use App\Entity\ApiKey;
use App\Repository\ApiKeyRepository;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class ApiTokenHandler implements AccessTokenHandlerInterface
{
public function __construct(private ApiKeyRepository $apiKeyRepository)
{
}
public function getUserBadgeFrom(string $credentials): UserBadge
{
$apiKey = $this->apiKeyRepository->findValidByToken($credentials);
if (!$apiKey) {
throw new BadCredentialsException('Invalid API token');
}
// Mettre à jour le dernier accès
$apiKey->setLastUsedAt(new \DateTime());
$this->apiKeyRepository->getEntityManager()->flush();
return new UserBadge($apiKey->getUser()->getPseudo());
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace App\Security;
use App\Repository\UserRepository;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
class ApiUserProvider implements UserProviderInterface
{
public function __construct(private UserRepository $userRepository)
{
}
/**
* Recharger l'utilisateur par pseudo (utilisé par le firewall)
*/
public function loadUserByIdentifier(string $identifier): UserInterface
{
$user = $this->userRepository->findOneBy(['pseudo' => $identifier]);
if (!$user) {
throw new UserNotFoundException(sprintf('User "%s" not found', $identifier));
}
return $user;
}
/**
* Vérifier si ce provider supporte la classe utilisateur
*/
public function supportsClass(string $class): bool
{
return $class === 'App\Entity\User';
}
/**
* Rafraîchir l'utilisateur (recharger depuis la BD)
*/
public function refreshUser(UserInterface $user): UserInterface
{
if (!$this->supportsClass($user::class)) {
throw new \InvalidArgumentException(sprintf('Instances of "%s" are not supported', $user::class));
}
$updatedUser = $this->userRepository->find($user->getId());
if (!$updatedUser) {
throw new UserNotFoundException(sprintf('User "%d" not found', $user->getId()));
}
return $updatedUser;
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\HttpUtils;
use Psr\Log\LoggerInterface;
class LoginFailureHandler implements AuthenticationFailureHandlerInterface
{
public function __construct(
private readonly HttpUtils $httpUtils,
private readonly ?LoggerInterface $logger = null,
) {
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
// Récupérer la session et ajouter un message flash
$session = $request->getSession();
$session->getFlashBag()->add('error', 'Alors, Tu as PERDU (tes identifiants) ?');
// Rediriger vers la page de référence ou vers la racine
$targetUrl = $request->headers->get('referer') ?? $this->httpUtils->generateUrl($request, 'app_login');
return $this->httpUtils->createRedirectResponse($request, $targetUrl);
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Http\HttpUtils;
class LoginSuccessHandler implements AuthenticationSuccessHandlerInterface
{
public function __construct(
private readonly HttpUtils $httpUtils,
) {
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
{
// Récupérer la session et ajouter un message flash
$session = $request->getSession();
$user = $token->getUser();
$session->getFlashBag()->add('info', 'Bon retour ' . $user->getPseudo() . ' !');
if ($user->hasDefaultPwd()) {
$session->getFlashBag()->add('warning', 'Vous utilisez un mot de passe par défaut, pensez à le changer dans les paramètres de votre compte !');
}
// Rediriger vers le chemin cible défini (ou la page par défaut)
if ($targetPath = $request->getSession()->get('_security.main.target_path')) {
$request->getSession()->remove('_security.main.target_path');
return new RedirectResponse($targetPath);
}
return new RedirectResponse($this->httpUtils->generateUri($request, 'app_accueil'));
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
class LogoutSuccessHandler implements LogoutSuccessHandlerInterface
{
public function onLogoutSuccess(Request $request, TokenInterface $token): Response
{
// Essayer de récupérer l'URL referer
$referer = $request->headers->get('referer');
// Vérifier que le referer n'est pas une URL de logout ou login
if ($referer && !str_contains($referer, '/logout') && !str_contains($referer, '/login')) {
return new RedirectResponse($referer);
}
// Sinon rediriger vers la page d'accueil
return new RedirectResponse('/');
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Service;
use App\Entity\Mod;
use App\Entity\Modpack;
use Doctrine\Common\Collections\Collection;
class ModpackZipService
{
public function __construct(private readonly string $tmpDir,
private readonly UploaderService $uploaderService,) {}
/**
* @param Mod[] $mods
*/
public function createZip(Modpack $modpack, array $mods): string
{
if (!is_dir($this->tmpDir)) {
mkdir($this->tmpDir, 0777, true);
}
$zipPath = $this->tmpDir . '/modpack-' . $modpack->getId() . '.zip';
$zip = new \ZipArchive();
$zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
foreach ($mods as $mod) {
$modPath = $this->uploaderService->getUploadDir(UploaderService::DIR_MOD) . '/' . $mod->getUri() ; // chemin réel vers le fichier
$zip->addFile($modPath, $mod->getNom() . '-' . $mod->getVersion() . '.jar');
}
$zip->close();
return $zipPath;
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Service;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class UploaderService
{
public const DIR_PP = 'pp';
public const DIR_MOD = 'mods';
public const DIR_FILE = 'files';
public function __construct(private readonly string $uploadDir) {}
public function getUploadDir(string $sousDir = self::DIR_FILE): string
{
return $this->uploadDir . '/' . $sousDir;
}
public function upload(UploadedFile $file, string $sousDir = self::DIR_FILE, bool $keepOriginalName = false): string
{
$dossier = $this->uploadDir . '/' . $sousDir;
if (!is_dir($dossier)) {
mkdir($dossier, 0755, true);
}
if ($keepOriginalName) {
// Garde le nom original du fichier
$nom = $file->getClientOriginalName();
if (file_exists($dossier . '/' . $nom)) {
throw new \RuntimeException("Le mod '$nom' existe déjà.");
}
} else {
// Génère un nom unique (comportement par défaut)
$nom = uniqid() . '.' . $file->guessExtension();
}
$file->move($dossier, $nom);
return $nom;
}
public function delete(string $nom, string $sousDir = self::DIR_FILE): ?bool
{
$chemin = $this->uploadDir . '/' . $sousDir . '/' . $nom;
if (file_exists($chemin)) {
return unlink($chemin);
}
return null; // Fichier non trouvé
}
}

View file

@ -2,8 +2,49 @@
{% block title %}Administration{% endblock %}
{% block body %}
<main>
</main>
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
<link rel="stylesheet" href="{{ asset('styles/administration/index.css') }}">
{% endblock %}
{% block titlePage %}
<div class="bulle-container">
<div class="bulle bulle-all-col bulle-nohover bulle-header">
<h1 class="bulle-title">Administration</h1>
</div>
</div>
{% endblock %}
{% block main %}
<div class="bulle-container">
<a href="{{ path("app_admin_modpacks") }}" class="bulle admin-card">
<span class="admin-card__icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
<path d="M5 7.5a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v.938l.4 1.599a1 1 0 0 1-.416 1.074l-.93.62a1 1 0 0 1-1.11 0l-.929-.62a1 1 0 0 1-.415-1.074L5 8.438zm2 0H6v.938a1 1 0 0 1-.03.243l-.4 1.598.93.62.929-.62-.4-1.598A1 1 0 0 1 7 8.438z"/>
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1h-2v1h-1v1h1v1h-1v1h1v1H6V5H5V4h1V3H5V2h1V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5z"/>
</svg>
</span>
<span class="admin-card__title">MODPACK</span>
<span class="admin-card__desc">selection des mods</span>
</a>
<a href="{{ path("app_admin_mods") }}" class="bulle admin-card">
<span class="admin-card__icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M14 4.5V11h-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5zM1.521 15.175a1.3 1.3 0 0 1-.082-.466h.765a.6.6 0 0 0 .073.27.5.5 0 0 0 .454.246q.285 0 .422-.164.138-.165.138-.466V11.85h.79v2.725q0 .66-.357 1.005-.354.345-.984.345a1.6 1.6 0 0 1-.568-.094 1.1 1.1 0 0 1-.408-.266 1.1 1.1 0 0 1-.243-.39m3.972-.354-.314 1.028h-.8l1.342-3.999h.926l1.336 3.999h-.84l-.314-1.028zm1.178-.59-.49-1.616h-.035l-.49 1.617zm2.342 1.618h.952l1.327-3.999h-.878l-.888 3.138h-.038L8.59 11.85h-.917zm3.087-1.028-.314 1.028h-.8l1.342-3.999h.926l1.336 3.999h-.84l-.314-1.028zm1.178-.59-.49-1.616h-.035l-.49 1.617z"/>
</svg>
</span>
<span class="admin-card__title">MODS</span>
<span class="admin-card__desc">gestion des mods</span>
</a>
<a href="{{ path("app_admin_users") }}" class="bulle admin-card">
<span class="admin-card__icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
<path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1zm-7.978-1L7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002-.014.002zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4m3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0M6.936 9.28a6 6 0 0 0-1.23-.247A7 7 0 0 0 5 9c-4 0-5 3-5 4q0 1 1 1h4.216A2.24 2.24 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816M4.92 10A5.5 5.5 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275ZM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0m3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4"/>
</svg>
</span>
<span class="admin-card__title">USERS</span>
<span class="admin-card__desc">gestion des utilisateurs</span>
</a>
</div>
{% endblock %}

View file

@ -0,0 +1,82 @@
{% extends 'base.html.twig' %}
{% set optFields = [] %}
{% for opt in form.mods_optionnels %}
{% set optFields = optFields|merge([opt]) %}
{% endfor %}
{% block title %}Administration{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.min.css">
<link rel="stylesheet" href="{{ asset('styles/globals/form.css') }}">
{% endblock %}
{% block titlePage %}
<div class="bulle-container">
<div class="bulle bulle-all-col bulle-nohover bulle-header">
<h1 class="bulle-title">Administration : Mods</h1>
</div>
</div>
{% endblock %}
{% block main %}
<div class="bulle-container">
{{ form_start(form, { attr: { class: 'bulle bulle-all-col form-wrapper' } }) }}
<div class="form-group">
{{ form_label(form.version, "", { label_attr: { class: 'form-label' } }) }}
{{ form_widget(form.version, { attr: { class: 'form-input' } }) }}
{{ form_errors(form.version) }}
</div>
<div class="form-group">
<label class="form-toggle">
{{ form_widget(form.downloadable) }}
<span class="form-toggle__track"></span>
<span class="form-toggle__label {% if form.downloadable.vars.disabled %}disabled{% endif %}">Téléchargeable</span>
</label>
{{ form_errors(form.downloadable) }}
</div>
<div class="form-group">
{{ form_label(form.mods, "", { label_attr: { class: 'form-label' } }) }}
<div class="form-checkbox-group"
data-controller="mod-optional">
{% for mod in form.mods %}
<div class="form-wrapper__row {% if not loop.last %}form-wrapper__row-line{% endif %}"
data-mod-optional-target="row">
<label class="form-checkbox-group-item">
{{ form_widget(mod, { attr: {
'data-mod-checkbox': '',
'data-action': 'change->mod-optional#toggle'
}}) }}
<span>{{ form_label(mod) }}</span>
</label>
<label class="form-toggle d-none"
data-opt-toggle>
{{ form_widget(optFields[loop.index0], { attr: { 'data-ui-opt': '' } }) }}
<span class="form-toggle__track"></span>
<span class="form-toggle__label">Optionnel</span>
</label>
</div>
{% endfor %}
</div>
{{ form_errors(form.mods) }}
</div>
<div class="form-wrapper__footer">
<a href="{{ path('app_admin_modpacks') }}" class="form-btn form-btn--cancel">Annuler</a>
<button type="submit" class="form-btn">Enregistrer</button>
</div>
{{ form_end(form) }}
</div>
{% endblock %}

View file

@ -0,0 +1,50 @@
{% extends 'base.html.twig' %}
{% block title %}Administration{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
<link rel="stylesheet" href="{{ asset('styles/administration/modpacks/index.css') }}">
{% endblock %}
{% block titlePage %}
<div class="bulle-container">
<div class="bulle bulle-all-col bulle-nohover bulle-header">
<h1 class="bulle-title">Administration : Modpacks</h1>
</div>
</div>
{% endblock %}
{% block main %}
<div class="bulle-container">
{# ADD BUTTON #}
<a href="{{ path("app_admin_modpacks_add") }}" class="bulle bulle-all-col modpack-card">
<span class="modpack-add-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2"/>
</svg>
</span>
<div class="modpack-name">
Créer un modpack
</div>
</a>
{% for modpack in modpacks %}
<div class="bulle bulle-all-col modpack-card"
data-controller="clickable-card"
data-href="{{ path('app_admin_modpacks_edit', {'id': modpack.id}) }}"
data-action="click->clickable-card#navigate">
<div class="modpack-name">{{ modpack.version }}</div>
<div class="modpack-space"></div>
<div class="modpack-number">{{ modpack.numberofmods }} mod{% if modpack.numberofmods>=2 %}s{% endif %}</div>
<div class="modpack-used {% if not modpack.downloadable %}non-utilise{% endif %}">
{% if modpack.downloadable %}Téléchargeable{% else %}Caché{% endif %}
</div>
<a href="{{ path('app_admin_modpacks_delete', {'id' : modpack.id}) }}" class="modpack-delete" data-action="click->clickable-card#stopPropagation">Supprimer</a>
</div>
{% endfor %}
</div>
{% endblock %}

View file

@ -0,0 +1,58 @@
{% extends 'base.html.twig' %}
{% block title %}Administration{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.min.css">
<link rel="stylesheet" href="{{ asset('styles/globals/form.css') }}">
{% endblock %}
{% block titlePage %}
<div class="bulle-container">
<div class="bulle bulle-all-col bulle-nohover bulle-header">
<h1 class="bulle-title">Administration : Mods</h1>
</div>
</div>
{% endblock %}
{% block main %}
<div class="bulle-container">
{{ form_start(form, { attr: { class: 'bulle bulle-all-col form-wrapper' } }) }}
<div class="form-group">
{{ form_label(form.nom, "", { label_attr: { class: 'form-label' } }) }}
{{ form_widget(form.nom, { attr: { class: 'form-input' } }) }}
{{ form_errors(form.nom) }}
</div>
<div class="form-group">
{{ form_label(form.version, "", { label_attr: { class: 'form-label' } }) }}
{{ form_widget(form.version, { attr: { class: 'form-input' } }) }}
{{ form_errors(form.version) }}
</div>
<div class="form-group">
{{ form_label(form.uri, "", { label_attr: { class: 'form-label' } }) }}
{{ form_widget(form.uri, { attr: { class: 'form-file' } }) }}
{{ form_errors(form.uri) }}
</div>
<div class="form-group">
{{ form_label(form.dependances, "", { label_attr: { class: 'form-label' } }) }}
{{ form_widget(form.dependances, { attr: {
class: 'form-tom-select-input',
'data-controller': 'tom-select',
} } ) }}
{{ form_errors(form.dependances) }}
</div>
<div class="form-wrapper__footer">
<a href="{{ path('app_admin_mods') }}" class="form-btn form-btn--cancel">Annuler</a>
<button type="submit" class="form-btn">Enregistrer</button>
</div>
{{ form_end(form) }}
</div>
{% endblock %}

View file

@ -0,0 +1,60 @@
{% extends 'base.html.twig' %}
{% block title %}Administration{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
<link rel="stylesheet" href="{{ asset('styles/administration/mods/index.css') }}">
{% endblock %}
{% block titlePage %}
<div class="bulle-container">
<div class="bulle bulle-all-col bulle-nohover bulle-header">
<h1 class="bulle-title">Administration : Mods</h1>
</div>
</div>
{% endblock %}
{% block main %}
<div class="bulle-container">
{# ADD BUTTON #}
<a href="{{ path("app_admin_mods_add") }}" class="bulle bulle-all-col mod-card">
<span class="mod-add-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2"/>
</svg>
</span>
<div class="mod-name">
Ajouter un mod
</div>
</a>
{% for mod in mods %}
<div class="bulle bulle-all-col mod-card"
data-controller="clickable-card"
data-href="{{ path('app_admin_mods_edit', {'idmod': mod.id}) }}"
data-action="click->clickable-card#navigate">
<div class="mod-name">{{ mod.nom }}</div>
<div class="mod-space"></div>
<div class="mod-version"> Version : {{ mod.version }}</div>
<div class="mod-deps">
{% for dependance in mod.dependances %}
<a href='{{ path('app_admin_mods_edit', {'idmod': dependance.id}) }}'
class="mod-dep" data-action="click->mod-card#stopPropagation">
{{ dependance.nom }} - {{ dependance.version }}
</a>
{% endfor %}
</div>
<div class="mod-used {% if not mod.isUsed %}non-utilise{% endif %}">
{% if mod.isUsed %}Utilisé{% else %}Non utilisé{% endif %}
</div>
<a href="{{ path('app_admin_mods_delete', {'idmod': mod.id}) }}" class="mod-delete" data-action="click->clickable-card#stopPropagation">Supprimer</a>
</div>
{% endfor %}
</div>
{% endblock %}

View file

@ -0,0 +1,71 @@
{% extends 'base.html.twig' %}
{% block title %}Administration{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
<link rel="stylesheet" href="{{ asset('styles/globals/form.css') }}">
{% endblock %}
{% block titlePage %}
<div class="bulle-container">
<div class="bulle bulle-all-col bulle-nohover bulle-header">
<h1 class="bulle-title">Administration : Users</h1>
</div>
</div>
{% endblock %}
{% block main %}
<div class="bulle-container">
{{ form_start(form, { attr: { class: 'bulle bulle-all-col form-wrapper' } }) }}
<div class="form-wrapper__row">
{# Colonne gauche : pseudo + password + joueur #}
<div class="form-wrapper__col">
<div class="form-group">
{{ form_label(form.pseudo, "", { label_attr: { class: 'form-label' } }) }}
{{ form_widget(form.pseudo, { attr: { class: 'form-input' } }) }}
{{ form_errors(form.pseudo) }}
</div>
<div class="form-group">
{{ form_label(form.password, "", { label_attr: { class: 'form-label' } }) }}
{{ form_widget(form.password, { attr: { class: 'form-input' } }) }}
{{ form_errors(form.password) }}
</div>
<div class="form-group">
{{ form_label(form.joueur, "", { label_attr: { class: 'form-label' } }) }}
{{ form_widget(form.joueur, { attr: { class: 'form-input form-select' } }) }}
{{ form_errors(form.joueur) }}
</div>
</div>
{# Colonne droite : rôles + theme #}
<div class="form-wrapper__col">
<div class="form-group">
{{ form_label(form.roles, "", { label_attr: { class: 'form-label' } }) }}
<div class="form-checkbox-group">
{% for role in form.roles %}
<label class="form-checkbox-group-item">
{{ form_widget(role) }}
<span>{{ form_label(role) }}</span>
</label>
{% endfor %}
</div>
{{ form_errors(form.roles) }}
</div>
</div>
</div>
<div class="form-wrapper__footer">
<a href="{{ path('app_admin_users') }}" class="form-btn form-btn--cancel">Annuler</a>
<button type="submit" class="form-btn">Enregistrer</button>
</div>
{{ form_end(form) }}
</div>
{% endblock %}

View file

@ -0,0 +1,73 @@
{% extends 'base.html.twig' %}
{% block title %}Administration{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
<link rel="stylesheet" href="{{ asset('styles/administration/users/index.css') }}">
{% endblock %}
{% block titlePage %}
<div class="bulle-container">
<div class="bulle bulle-all-col bulle-nohover bulle-header">
<h1 class="bulle-title">Administration : Users</h1>
</div>
</div>
{% endblock %}
{% block main %}
<div class="bulle-container">
{# ADD BUTTON #}
<a href="{{ path("app_admin_users_add") }}" class="bulle bulle-all-col user-card">
<span class="pp-svg-wrapper">
<svg width="15" height="15" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="pp-svg">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</span>
<div class="user-pseudo">
Ajouter un utilisateur
</div>
</a>
{% for user in users %}
<a {% if app.user and app.user.isAncestorOf(user) %} href="{{ path("app_admin_users_edit", {'iduser':user.id}) }}" {% endif %}
class="bulle bulle-all-col user-card">
{% if user.uripp %}
<img src="{{ user.globalURLPP }}" alt="photo" class="pp-img">
{% else %}
<span class="pp-svg-wrapper">
<svg width="15" height="15" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="pp-svg">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</span>
{% endif %}
<div class="user-pseudo">
{{ user.pseudo }}
</div>
<div class="user-space"></div>
<div class="user-role">
{% if user.admin %}
admin
{% else %}
user
{% endif %}
</div>
<div
class="user-editable {% if not (app.user and app.user.isAncestorOf(user)) %}non-modifiable{% endif %}">
{% if app.user and app.user.isAncestorOf(user) %}
modifiable
{% else %}
non modifiable
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% endblock %}

View file

@ -2,8 +2,9 @@
<html data-theme="light" lang="fr">
<head>
<meta charset="UTF-8">
<title>{% block title %}CraftWorld{% endblock %}</title>
<title>{% block title %}Ploush{% endblock %}</title>
<link rel="icon" href="...">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{# Anti-flash - DOIT être avant tout le reste #}
<script>
@ -20,6 +21,8 @@
<link rel="stylesheet" href="{{ asset('styles/app.css') }}">
{# Styles du header #}
<link rel="stylesheet" href="{{ asset('styles/partials/header.css') }}">
{#Style des flash messages #}
<link rel="stylesheet" href="{{ asset('styles/partials/flash.css') }}">
{% block stylesheets %}{% endblock %}
@ -34,8 +37,23 @@
data-bg-dark="{{ asset('images/bg-dark.png') }}">
{% include 'partials/_header.html.twig' %}
<main>
{% block titlePage %}{% endblock %}
{% block body %}{% endblock %}
{% block flash_messages %}
<div class="flash-container">
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class="flash flash-{{ label }}">
<span class="flash-body">{{ message }}</span>
<button class="flash-close" onclick="this.parentElement.remove()" aria-label="Fermer">✕</button>
</div>
{% endfor %}
{% endfor %}
</div>
{% endblock %}
{% block main %}{% endblock %}
</main>
</body>
</html>

View file

@ -0,0 +1,57 @@
{% extends 'base.html.twig' %}
{% block title %} Modpack {% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('styles/vitrine/index.css') }}">
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
<link rel="stylesheet" href="{{ asset('styles/globals/form.css') }}">
{% endblock %}
{% block main %}
<div class="bulle-container">
<section class="bulle bulle-all-col flex-column align-items-center">
<h1 class="text-center">Télécharger notre Modpack</h1>
<div class="form-group">
<label class="form-label" for="version-select">Version</label>
<select id="version-select"
class="form-input form-select"
data-controller="modpack-download"
data-action="change->modpack-download#changeVersion">
{% for mp in modpacks %}
<option value="{{ mp.id }}" {{ mp.id == modpack.id ? 'selected' : '' }}>
{{ mp.version }}
</option>
{% endfor %}
</select>
</div>
</section>
{{ form_start(form, {
'attr': {
class: 'bulle bulle-all-col form-wrapper',
'data-controller': 'modpack-download',
'data-modpack-download-url-value': path('app_modpack_download', {'id': modpack.id}),
'data-action': 'submit->modpack-download#submit'
}
}) }}
<div class="form-group">
{{ form_label(form.mods_optionnels, "", { label_attr: { class: 'form-label' } }) }}
<div class="form-checkbox-group">
{% for mod in form.mods_optionnels %}
<label class="form-checkbox-group-item">
{{ form_widget(mod) }}
<span>{{ form_label(mod) }}</span>
</label>
{% endfor %}
</div>
{{ form_errors(form.mods_optionnels) }}
</div>
<div class="form-wrapper__footer">
<button type="submit" class="form-btn">Télécharger</button>
</div>
{{ form_end(form) }}
</div>
{% endblock %}

View file

@ -19,8 +19,8 @@
class="nav-link {{ current_route == 'app_accueil' ? 'active' }}">
Accueil
</a>
<a href="{{ path('app_accueil') }}"
class="nav-link {{ current_route == 'app_home' ? 'active' }}">
<a href="{{ path('app_modpack') }}"
class="nav-link {{ current_route == 'app_modpack' ? 'active' }}">
Modpack
</a>
<a href="{{ path('app_accueil') }}"

View file

@ -27,6 +27,16 @@
<span class="dropdown-title">Connexion</span>
{# Afficher les messages d'erreur flash #}
{% set errors = app.flashes('error') %}
{% if errors %}
<div class="login-error">
{% for message in errors %}
<div class="alert alert-danger">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
<form action="{{ path('app_login') }}" method="post" >
<div class="form-field">
@ -54,6 +64,11 @@
name="_csrf_token"
value="{{ csrf_token('authenticate') }}">
{# Rediriger vers la page courante après connexion #}
<input type="hidden"
name="_target_path"
value="{{ app.request.uri }}">
{# Retenir la page courante pour y revenir en cas d'erreur #}
<input type="hidden"
name="_failure_path"
@ -63,7 +78,6 @@
</form>
</div>
</div>

View file

@ -11,8 +11,8 @@
<button class="login-btn" id="loginToggle"
aria-haspopup="true" aria-expanded="false"
data-header-dropdown-target="toggle">
{% if app.user.uriPp is not null %}
<img src="{{ asset(app.user.uri_pp) }}" alt="Photo de profil de {{ app.user.pseudo }}" class="profile-pic">
{% if app.user.globalURLpp is not null %}
<img src="{{ asset(app.user.globalURLpp) }}" alt="Photo de profil de {{ app.user.pseudo }}" class="profile-pic">
{% else %}
<svg width="15" height="15" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
@ -32,13 +32,13 @@
<span class="dropdown-title">Bonjour {{ app.user.pseudo }}</span>
{% if is_granted("ROLE_ADMIN") %}
<a href="{{ path('app_administration') }}"
<a href="{{ path('app_admin') }}"
class="dropdown-link {{ current_route == 'app_administration' ? 'active' }}">
Administration
</a>
{% endif %}
<a href="{{ path('app_accueil') }}"
<a href="{{ path('app_settings') }}"
class="dropdown-link {{ current_route == 'app_home' ? 'active' }}">
Parametre
</a>

View file

@ -20,6 +20,9 @@
<label for="password">Password</label>
<input type="password" name="_password" id="password" class="form-control" autocomplete="current-password" required>
<input type="hidden" name="_csrf_token" data-controller="csrf-protection" value="{{ csrf_token('authenticate') }}">
{% if app.request.headers.get('referer') %}
<input type="hidden" name="_target_path" value="{{ app.request.headers.get('referer') }}">
{% endif %}
{#
Uncomment this section and add a remember_me option below your firewall to activate remember me functionality.

View file

@ -0,0 +1,32 @@
{% extends 'base.html.twig' %}
{% set current_route = app.request.attributes.get('_route') %}
{% block title %}Parametre{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
<link rel="stylesheet" href="{{ asset('styles/globals/navbutton.css') }}">
{% endblock %}
{% block titlePage %}
<div class="bulle-container">
<div class="bulle bulle-all-col bulle-nohover bulle-header">
<h1 class="bulle-title">Parametre : {{ app.user.pseudo }}</h1>
</div>
<nav class="bulle bulle-all-col bulle-nohover d-flex flex-nowrap flex-row justify-content-start align-items-center gap-2">
<a href="{{ path("app_settings_profile") }}" class="nav-link {{ current_route == 'app_settings_profile' ? 'active' }}">Profile</a>
<a href="{{ path("app_settings_security") }}" class="nav-link {{ current_route == 'app_settings_security' ? 'active' }}">Securité</a>
<div class="nav-link {{ current_route == 'app_settings_territory' ? 'active' }}">Territoires</div>
</nav>
</div>
{% endblock %}
{% block main %}
<div class="bulle-container">
{% block form %}
{% endblock %}
</div>
{% endblock %}

View file

@ -0,0 +1,49 @@
{% extends 'settings/base.html.twig' %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
<link rel="stylesheet" href="{{ asset('styles/globals/form.css') }}">
{% endblock %}
{% block main %}
<div class="bulle-container">
{{ form_start(form, { attr: { class: 'bulle bulle-all-col form-wrapper' } }) }}
<div class="form-wrapper__row">
<div class="form-wrapper__col">
<div class="form-group">
{{ form_label(form.pseudo, "" , { label_attr: { class: 'form-label' } }) }}
{{ form_widget(form.pseudo, { attr: { class: 'form-input' } }) }}
{{ form_errors(form.pseudo) }}
</div>
<div class="form-group">
{{ form_label(form.bio, "" , { label_attr: { class: 'form-label' } }) }}
{{ form_widget(form.bio, { attr: { class: 'form-input' } }) }}
{{ form_errors(form.bio) }}
</div>
</div>
<div class="form-wrapper__col">
<div class="form-group">
{{ form_label(form.uri_pp, "" , { label_attr: { class: 'form-label' } }) }}
{{ form_widget(form.uri_pp, { attr: { class: 'form-input' } }) }}
{{ form_errors(form.uri_pp) }}
</div>
<div class="form-group">
{{ form_label(form.profile_picture_file, "" , { label_attr: { class: 'form-label' } }) }}
{{ form_widget(form.profile_picture_file, { attr: { class: 'form-file' } }) }}
{{ form_help(form.profile_picture_file, { help_attr: { class: 'form-helper' } } ) }}
{{ form_errors(form.profile_picture_file) }}
</div>
</div>
</div>
<div class="form-wrapper__footer">
<button type="submit" class="form-btn">Enregistrer</button>
</div>
{{ form_end(form) }}
</div>
{% endblock %}

View file

@ -0,0 +1,33 @@
{% extends 'settings/base.html.twig' %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('styles/globals/form.css') }}">
{% endblock %}
{% block form %}
{{ form_start(form, { attr: { class: 'bulle bulle-all-col form-wrapper' } }) }}
<div class="form-group">
{{ form_label(form.current_password, "" , { label_attr: { class: 'form-label' } }) }}
{{ form_widget(form.current_password, { attr: { class: 'form-input' } }) }}
{{ form_errors(form.current_password) }}
</div>
<div class="form-group">
{{ form_label(form.new_password, "" , { label_attr: { class: 'form-label' } }) }}
{{ form_widget(form.new_password, { attr: { class: 'form-input' } }) }}
{{ form_errors(form.new_password) }}
</div>
<div class="form-group">
{{ form_label(form.new_password_confirmation, "" , { label_attr: { class: 'form-label' } }) }}
{{ form_widget(form.new_password_confirmation, { attr: { class: 'form-input' } }) }}
{{ form_errors(form.new_password_confirmation) }}
</div>
<div class="form-wrapper__footer">
<button type="submit" class="form-btn">Enregistrer</button>
</div>
{{ form_end(form) }}
{% endblock %}

View file

@ -4,12 +4,12 @@
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('styles/vitrine.css') }}">
<link rel="stylesheet" href="{{ asset('styles/vitrine/index.css') }}">
<link rel="stylesheet" href="{{ asset('styles/globals/bulles.css') }}">
{% endblock %}
{% block body %}
<main class="bulle-container">
{% block main %}
<div class="bulle-container">
<section class="bulle bulle-all-col flex-column align-items-center">
<h1 class="text-center">Bienvenue sur le serveur de Ploush</h1>
<p class="text-center">Le serveur est accessible a l'adresse :
@ -33,5 +33,5 @@
<h4>Bienvenue sur le serveur de Ploush</h4>
<p>Celui-ci est accessible a l'adresse <span class="copy-link" data-controller="copy-link" data-action="click->copy-link#copy" data-copy-link-target="link" data-copy-text="ploush.top">ploush.top</span></p>
</section>
</main>
</div>
{% endblock %}