From 303fc7bcb5097166b1346ebd554eb6e00a5fd0f3 Mon Sep 17 00:00:00 2001 From: Ploush Date: Thu, 9 Apr 2026 14:58:12 +0200 Subject: [PATCH] ajout trop de chose rappel pour moi : a ne plus reproduire --- .gitignore | 3 + api-client.php | 188 ++++ .../controllers/clickable-card_controller.js | 11 + .../controllers/header_dropdown_controller.js | 10 - assets/controllers/mod_optional_controller.js | 26 + .../modpack_download_controller.js | 21 + assets/controllers/tom-select_controller.js | 21 + assets/styles/administration/index.css | 66 ++ .../styles/administration/modpacks/index.css | 153 ++++ assets/styles/administration/mods/index.css | 153 ++++ assets/styles/administration/users/index.css | 104 +++ assets/styles/app.css | 11 +- assets/styles/globals/bulles.css | 25 +- assets/styles/globals/form.css | 512 +++++++++++ assets/styles/globals/navbutton.css | 72 ++ assets/styles/partials/flash.css | 134 +++ assets/styles/partials/header.css | 121 +-- assets/styles/vitrine.css | 9 - assets/styles/vitrine/index.css | 1 + composer.json | 3 +- config/packages/security.yaml | 35 +- config/routes.yaml | 9 +- config/services.yaml | 6 + document/DOCUMENTATION_API.md | 825 ++++++++++++++++++ document/GUIDE_USER_RAPIDE_API.md | 207 +++++ document/INDEX.md | 159 ++++ importmap.php | 13 + migrations/Version20260403163636.php | 31 + migrations/Version20260403164519.php | 37 + migrations/Version20260405171429.php | 37 + migrations/Version20260406173517.php | 31 + migrations/Version20260407125351.php | 31 + src/Command/ApiKeyCreateCommand.php | 79 ++ src/Command/ApiTestCommand.php | 61 ++ src/Controller/AdministrationController.php | 375 +++++++- src/Controller/Api/V1/ApiKeyController.php | 156 ++++ src/Controller/Api/V1/StateController.php | 55 ++ src/Controller/ModpackController.php | 87 ++ src/Controller/SecurityController.php | 3 +- src/Controller/SettingsController.php | 98 +++ src/Controller/VitrineController.php | 5 +- src/Entity/ApiKey.php | 151 ++++ src/Entity/Mod.php | 20 +- src/Entity/Modpack.php | 42 + src/Entity/User.php | 68 +- src/EventListener/ApiExceptionListener.php | 106 +++ src/EventListener/ApiForceSessionListener.php | 36 + .../AccessDeniedSubscriber.php | 39 + src/EventSubscriber/LogoutSubscriber.php | 32 + src/Form/Mod/ModType.php | 91 ++ src/Form/Modpack/ModpackDownloadType.php | 45 + src/Form/Modpack/ModpackType.php | 65 ++ src/Form/User/Settings/ProfileType.php | 82 ++ src/Form/User/Settings/SecurityType.php | 52 ++ src/Form/User/UserFormType.php | 96 ++ src/Repository/ApiKeyRepository.php | 54 ++ src/Repository/ModRepository.php | 46 + src/Security/AccessDeniedHandler.php | 19 + src/Security/ApiAccessDeniedHandler.php | 27 + src/Security/ApiAuthenticationEntryPoint.php | 27 + src/Security/ApiBearerTokenAuthenticator.php | 60 ++ src/Security/ApiTokenHandler.php | 33 + src/Security/ApiUserProvider.php | 56 ++ src/Security/LoginFailureHandler.php | 34 + src/Security/LoginSuccessHandler.php | 38 + src/Security/LogoutSuccessHandler.php | 27 + src/Service/ModpackZipService.php | 37 + src/Service/UploaderService.php | 54 ++ templates/administration/index.html.twig | 49 +- .../administration/modpacks/edit.html.twig | 82 ++ .../administration/modpacks/index.html.twig | 50 ++ templates/administration/mods/edit.html.twig | 58 ++ templates/administration/mods/index.html.twig | 60 ++ templates/administration/users/edit.html.twig | 71 ++ .../administration/users/index.html.twig | 73 ++ templates/base.html.twig | 22 +- templates/modpack/index.html.twig | 57 ++ templates/partials/_header.html.twig | 4 +- templates/partials/_login_dropdown.html.twig | 18 +- templates/partials/_logout_dropdown.html.twig | 8 +- templates/security/login.html.twig | 3 + templates/settings/base.html.twig | 32 + templates/settings/profile.html.twig | 49 ++ templates/settings/security.html.twig | 33 + templates/vitrine/index.html.twig | 8 +- 85 files changed, 5959 insertions(+), 139 deletions(-) create mode 100755 api-client.php create mode 100644 assets/controllers/clickable-card_controller.js create mode 100644 assets/controllers/mod_optional_controller.js create mode 100644 assets/controllers/modpack_download_controller.js create mode 100644 assets/controllers/tom-select_controller.js create mode 100644 assets/styles/administration/index.css create mode 100644 assets/styles/administration/modpacks/index.css create mode 100644 assets/styles/administration/mods/index.css create mode 100644 assets/styles/administration/users/index.css create mode 100644 assets/styles/globals/form.css create mode 100644 assets/styles/globals/navbutton.css create mode 100644 assets/styles/partials/flash.css delete mode 100644 assets/styles/vitrine.css create mode 100644 assets/styles/vitrine/index.css create mode 100644 document/DOCUMENTATION_API.md create mode 100644 document/GUIDE_USER_RAPIDE_API.md create mode 100644 document/INDEX.md create mode 100644 migrations/Version20260403163636.php create mode 100644 migrations/Version20260403164519.php create mode 100644 migrations/Version20260405171429.php create mode 100644 migrations/Version20260406173517.php create mode 100644 migrations/Version20260407125351.php create mode 100644 src/Command/ApiKeyCreateCommand.php create mode 100644 src/Command/ApiTestCommand.php create mode 100644 src/Controller/Api/V1/ApiKeyController.php create mode 100644 src/Controller/Api/V1/StateController.php create mode 100644 src/Controller/ModpackController.php create mode 100644 src/Controller/SettingsController.php create mode 100644 src/Entity/ApiKey.php create mode 100644 src/EventListener/ApiExceptionListener.php create mode 100644 src/EventListener/ApiForceSessionListener.php create mode 100644 src/EventSubscriber/AccessDeniedSubscriber.php create mode 100644 src/EventSubscriber/LogoutSubscriber.php create mode 100644 src/Form/Mod/ModType.php create mode 100644 src/Form/Modpack/ModpackDownloadType.php create mode 100644 src/Form/Modpack/ModpackType.php create mode 100644 src/Form/User/Settings/ProfileType.php create mode 100644 src/Form/User/Settings/SecurityType.php create mode 100644 src/Form/User/UserFormType.php create mode 100644 src/Repository/ApiKeyRepository.php create mode 100644 src/Security/AccessDeniedHandler.php create mode 100644 src/Security/ApiAccessDeniedHandler.php create mode 100644 src/Security/ApiAuthenticationEntryPoint.php create mode 100644 src/Security/ApiBearerTokenAuthenticator.php create mode 100644 src/Security/ApiTokenHandler.php create mode 100644 src/Security/ApiUserProvider.php create mode 100644 src/Security/LoginFailureHandler.php create mode 100644 src/Security/LoginSuccessHandler.php create mode 100644 src/Security/LogoutSuccessHandler.php create mode 100644 src/Service/ModpackZipService.php create mode 100644 src/Service/UploaderService.php create mode 100644 templates/administration/modpacks/edit.html.twig create mode 100644 templates/administration/modpacks/index.html.twig create mode 100644 templates/administration/mods/edit.html.twig create mode 100644 templates/administration/mods/index.html.twig create mode 100644 templates/administration/users/edit.html.twig create mode 100644 templates/administration/users/index.html.twig create mode 100644 templates/modpack/index.html.twig create mode 100644 templates/settings/base.html.twig create mode 100644 templates/settings/profile.html.twig create mode 100644 templates/settings/security.html.twig diff --git a/.gitignore b/.gitignore index 31d3290..b7bfae0 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/api-client.php b/api-client.php new file mode 100755 index 0000000..0a4e73f --- /dev/null +++ b/api-client.php @@ -0,0 +1,188 @@ +#!/usr/bin/env php +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; +} + diff --git a/assets/controllers/clickable-card_controller.js b/assets/controllers/clickable-card_controller.js new file mode 100644 index 0000000..6153a3f --- /dev/null +++ b/assets/controllers/clickable-card_controller.js @@ -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(); + } +} diff --git a/assets/controllers/header_dropdown_controller.js b/assets/controllers/header_dropdown_controller.js index ee93fc0..96649f0 100644 --- a/assets/controllers/header_dropdown_controller.js +++ b/assets/controllers/header_dropdown_controller.js @@ -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'); } } - diff --git a/assets/controllers/mod_optional_controller.js b/assets/controllers/mod_optional_controller.js new file mode 100644 index 0000000..29fd756 --- /dev/null +++ b/assets/controllers/mod_optional_controller.js @@ -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; + } +} diff --git a/assets/controllers/modpack_download_controller.js b/assets/controllers/modpack_download_controller.js new file mode 100644 index 0000000..b746544 --- /dev/null +++ b/assets/controllers/modpack_download_controller.js @@ -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 + } +} diff --git a/assets/controllers/tom-select_controller.js b/assets/controllers/tom-select_controller.js new file mode 100644 index 0000000..ac05950 --- /dev/null +++ b/assets/controllers/tom-select_controller.js @@ -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(); + } + } +} diff --git a/assets/styles/administration/index.css b/assets/styles/administration/index.css new file mode 100644 index 0000000..69ac295 --- /dev/null +++ b/assets/styles/administration/index.css @@ -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); +} + diff --git a/assets/styles/administration/modpacks/index.css b/assets/styles/administration/modpacks/index.css new file mode 100644 index 0000000..62d5aef --- /dev/null +++ b/assets/styles/administration/modpacks/index.css @@ -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; +} diff --git a/assets/styles/administration/mods/index.css b/assets/styles/administration/mods/index.css new file mode 100644 index 0000000..6e2fffe --- /dev/null +++ b/assets/styles/administration/mods/index.css @@ -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; +} diff --git a/assets/styles/administration/users/index.css b/assets/styles/administration/users/index.css new file mode 100644 index 0000000..432c775 --- /dev/null +++ b/assets/styles/administration/users/index.css @@ -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); +} diff --git a/assets/styles/app.css b/assets/styles/app.css index 439ba3a..b5715c9 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -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 { diff --git a/assets/styles/globals/bulles.css b/assets/styles/globals/bulles.css index 9d6d3e2..105bf3a 100644 --- a/assets/styles/globals/bulles.css +++ b/assets/styles/globals/bulles.css @@ -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); +} diff --git a/assets/styles/globals/form.css b/assets/styles/globals/form.css new file mode 100644 index 0000000..10dd3c2 --- /dev/null +++ b/assets/styles/globals/form.css @@ -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; +} diff --git a/assets/styles/globals/navbutton.css b/assets/styles/globals/navbutton.css new file mode 100644 index 0000000..9010e02 --- /dev/null +++ b/assets/styles/globals/navbutton.css @@ -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; } diff --git a/assets/styles/partials/flash.css b/assets/styles/partials/flash.css new file mode 100644 index 0000000..de61b6b --- /dev/null +++ b/assets/styles/partials/flash.css @@ -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); } +} diff --git a/assets/styles/partials/header.css b/assets/styles/partials/header.css index d3f4885..0554d16 100644 --- a/assets/styles/partials/header.css +++ b/assets/styles/partials/header.css @@ -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; +} diff --git a/assets/styles/vitrine.css b/assets/styles/vitrine.css deleted file mode 100644 index 5dacf09..0000000 --- a/assets/styles/vitrine.css +++ /dev/null @@ -1,9 +0,0 @@ -body{ - display: flex; - flex-direction: column; - align-items: center; -} -main { - width: 100%; - max-width: 1200px; -} diff --git a/assets/styles/vitrine/index.css b/assets/styles/vitrine/index.css new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/assets/styles/vitrine/index.css @@ -0,0 +1 @@ + diff --git a/composer.json b/composer.json index 49cb762..cc952cc 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/config/packages/security.yaml b/config/packages/security.yaml index f7cf639..17067cc 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -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: diff --git a/config/routes.yaml b/config/routes.yaml index cef258c..9ea3c55 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -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/ diff --git a/config/services.yaml b/config/services.yaml index 79b8ce2..454acfe 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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 + diff --git a/document/DOCUMENTATION_API.md b/document/DOCUMENTATION_API.md new file mode 100644 index 0000000..c47b3a0 --- /dev/null +++ b/document/DOCUMENTATION_API.md @@ -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** + diff --git a/document/GUIDE_USER_RAPIDE_API.md b/document/GUIDE_USER_RAPIDE_API.md new file mode 100644 index 0000000..31cc5c8 --- /dev/null +++ b/document/GUIDE_USER_RAPIDE_API.md @@ -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!** 🎉 + diff --git a/document/INDEX.md b/document/INDEX.md new file mode 100644 index 0000000..b9c4de6 --- /dev/null +++ b/document/INDEX.md @@ -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** + diff --git a/importmap.php b/importmap.php index eaf8abd..016d32f 100644 --- a/importmap.php +++ b/importmap.php @@ -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', + ], ]; diff --git a/migrations/Version20260403163636.php b/migrations/Version20260403163636.php new file mode 100644 index 0000000..d871de6 --- /dev/null +++ b/migrations/Version20260403163636.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/migrations/Version20260403164519.php b/migrations/Version20260403164519.php new file mode 100644 index 0000000..fbd2c07 --- /dev/null +++ b/migrations/Version20260403164519.php @@ -0,0 +1,37 @@ +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 \'\''); + } +} diff --git a/migrations/Version20260405171429.php b/migrations/Version20260405171429.php new file mode 100644 index 0000000..292aad6 --- /dev/null +++ b/migrations/Version20260405171429.php @@ -0,0 +1,37 @@ +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'); + } +} diff --git a/migrations/Version20260406173517.php b/migrations/Version20260406173517.php new file mode 100644 index 0000000..bbfba42 --- /dev/null +++ b/migrations/Version20260406173517.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/migrations/Version20260407125351.php b/migrations/Version20260407125351.php new file mode 100644 index 0000000..20a03b1 --- /dev/null +++ b/migrations/Version20260407125351.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/src/Command/ApiKeyCreateCommand.php b/src/Command/ApiKeyCreateCommand.php new file mode 100644 index 0000000..c24f220 --- /dev/null +++ b/src/Command/ApiKeyCreateCommand.php @@ -0,0 +1,79 @@ +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; + } +} + diff --git a/src/Command/ApiTestCommand.php b/src/Command/ApiTestCommand.php new file mode 100644 index 0000000..9b1057d --- /dev/null +++ b/src/Command/ApiTestCommand.php @@ -0,0 +1,61 @@ +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("$route - $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; + } +} + diff --git a/src/Controller/AdministrationController.php b/src/Controller/AdministrationController.php index caad0cb..139364a 100644 --- a/src/Controller/AdministrationController.php +++ b/src/Controller/AdministrationController.php @@ -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'); + } + } diff --git a/src/Controller/Api/V1/ApiKeyController.php b/src/Controller/Api/V1/ApiKeyController.php new file mode 100644 index 0000000..13492a8 --- /dev/null +++ b/src/Controller/Api/V1/ApiKeyController.php @@ -0,0 +1,156 @@ +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']); + } +} diff --git a/src/Controller/Api/V1/StateController.php b/src/Controller/Api/V1/StateController.php new file mode 100644 index 0000000..20269a0 --- /dev/null +++ b/src/Controller/Api/V1/StateController.php @@ -0,0 +1,55 @@ +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'; + } +} diff --git a/src/Controller/ModpackController.php b/src/Controller/ModpackController.php new file mode 100644 index 0000000..5a2afd1 --- /dev/null +++ b/src/Controller/ModpackController.php @@ -0,0 +1,87 @@ +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; + } +} diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index d052e00..6df43dc 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -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(); diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php new file mode 100644 index 0000000..d4a560a --- /dev/null +++ b/src/Controller/SettingsController.php @@ -0,0 +1,98 @@ +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, + ]); + } +} diff --git a/src/Controller/VitrineController.php b/src/Controller/VitrineController.php index 762af2e..edb343d 100644 --- a/src/Controller/VitrineController.php +++ b/src/Controller/VitrineController.php @@ -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', []); + } } diff --git a/src/Entity/ApiKey.php b/src/Entity/ApiKey.php new file mode 100644 index 0000000..f4fb8a1 --- /dev/null +++ b/src/Entity/ApiKey.php @@ -0,0 +1,151 @@ +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; + } +} + diff --git a/src/Entity/Mod.php b/src/Entity/Mod.php index 874d19f..b1c8f0c 100644 --- a/src/Entity/Mod.php +++ b/src/Entity/Mod.php @@ -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 + * . */ #[ORM\ManyToMany(targetEntity: self::class, mappedBy: 'dependances')] private Collection $soumis; - /** - * @var Collection - */ - #[ORM\ManyToMany(targetEntity: Modpack::class, inversedBy: 'mods')] - private Collection $constitue; - /** * @var Collection */ #[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(); + } + } diff --git a/src/Entity/Modpack.php b/src/Entity/Modpack.php index f46b394..0543536 100644 --- a/src/Entity/Modpack.php +++ b/src/Entity/Modpack.php @@ -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); + } } diff --git a/src/Entity/User.php b/src/Entity/User.php index 2367d6e..f3fe6dc 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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 $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; + } } diff --git a/src/EventListener/ApiExceptionListener.php b/src/EventListener/ApiExceptionListener.php new file mode 100644 index 0000000..0b7f5bb --- /dev/null +++ b/src/EventListener/ApiExceptionListener.php @@ -0,0 +1,106 @@ +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); + } + } +} diff --git a/src/EventListener/ApiForceSessionListener.php b/src/EventListener/ApiForceSessionListener.php new file mode 100644 index 0000000..b712197 --- /dev/null +++ b/src/EventListener/ApiForceSessionListener.php @@ -0,0 +1,36 @@ +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 + } + } +} + diff --git a/src/EventSubscriber/AccessDeniedSubscriber.php b/src/EventSubscriber/AccessDeniedSubscriber.php new file mode 100644 index 0000000..810544e --- /dev/null +++ b/src/EventSubscriber/AccessDeniedSubscriber.php @@ -0,0 +1,39 @@ + ['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('/')); + } + } +} + diff --git a/src/EventSubscriber/LogoutSubscriber.php b/src/EventSubscriber/LogoutSubscriber.php new file mode 100644 index 0000000..d54828f --- /dev/null +++ b/src/EventSubscriber/LogoutSubscriber.php @@ -0,0 +1,32 @@ + '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('/')); + } + } +} + diff --git a/src/Form/Mod/ModType.php b/src/Form/Mod/ModType.php new file mode 100644 index 0000000..7e8cfd3 --- /dev/null +++ b/src/Form/Mod/ModType.php @@ -0,0 +1,91 @@ +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, + ]); + } +} diff --git a/src/Form/Modpack/ModpackDownloadType.php b/src/Form/Modpack/ModpackDownloadType.php new file mode 100644 index 0000000..f8674ea --- /dev/null +++ b/src/Form/Modpack/ModpackDownloadType.php @@ -0,0 +1,45 @@ +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, + ]); + } +} diff --git a/src/Form/Modpack/ModpackType.php b/src/Form/Modpack/ModpackType.php new file mode 100644 index 0000000..6fcfd97 --- /dev/null +++ b/src/Form/Modpack/ModpackType.php @@ -0,0 +1,65 @@ +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, + ]); + } +} diff --git a/src/Form/User/Settings/ProfileType.php b/src/Form/User/Settings/ProfileType.php new file mode 100644 index 0000000..aabb087 --- /dev/null +++ b/src/Form/User/Settings/ProfileType.php @@ -0,0 +1,82 @@ +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 + ]); + } +} diff --git a/src/Form/User/Settings/SecurityType.php b/src/Form/User/Settings/SecurityType.php new file mode 100644 index 0000000..0e4bb4d --- /dev/null +++ b/src/Form/User/Settings/SecurityType.php @@ -0,0 +1,52 @@ +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 + ]); + } +} diff --git a/src/Form/User/UserFormType.php b/src/Form/User/UserFormType.php new file mode 100644 index 0000000..67242a2 --- /dev/null +++ b/src/Form/User/UserFormType.php @@ -0,0 +1,96 @@ +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, + ]); + } +} diff --git a/src/Repository/ApiKeyRepository.php b/src/Repository/ApiKeyRepository.php new file mode 100644 index 0000000..284f2fc --- /dev/null +++ b/src/Repository/ApiKeyRepository.php @@ -0,0 +1,54 @@ + + */ +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(); + } +} + diff --git a/src/Repository/ModRepository.php b/src/Repository/ModRepository.php index adacec9..663d953 100644 --- a/src/Repository/ModRepository.php +++ b/src/Repository/ModRepository.php @@ -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 // */ diff --git a/src/Security/AccessDeniedHandler.php b/src/Security/AccessDeniedHandler.php new file mode 100644 index 0000000..36d3e43 --- /dev/null +++ b/src/Security/AccessDeniedHandler.php @@ -0,0 +1,19 @@ + 'Forbidden', + 'message' => 'You do not have permission to access this resource', + ], + 403 // HTTP 403 Forbidden + ); + } +} + diff --git a/src/Security/ApiAuthenticationEntryPoint.php b/src/Security/ApiAuthenticationEntryPoint.php new file mode 100644 index 0000000..1dc8f96 --- /dev/null +++ b/src/Security/ApiAuthenticationEntryPoint.php @@ -0,0 +1,27 @@ + 'Unauthorized', + 'message' => 'Authentication required', + ], + 401 // HTTP 401 Unauthorized + ); + } +} + diff --git a/src/Security/ApiBearerTokenAuthenticator.php b/src/Security/ApiBearerTokenAuthenticator.php new file mode 100644 index 0000000..55e9e32 --- /dev/null +++ b/src/Security/ApiBearerTokenAuthenticator.php @@ -0,0 +1,60 @@ +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 + ); + } +} + diff --git a/src/Security/ApiTokenHandler.php b/src/Security/ApiTokenHandler.php new file mode 100644 index 0000000..b1e902f --- /dev/null +++ b/src/Security/ApiTokenHandler.php @@ -0,0 +1,33 @@ +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()); + } +} + diff --git a/src/Security/ApiUserProvider.php b/src/Security/ApiUserProvider.php new file mode 100644 index 0000000..3686ba4 --- /dev/null +++ b/src/Security/ApiUserProvider.php @@ -0,0 +1,56 @@ +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; + } +} + diff --git a/src/Security/LoginFailureHandler.php b/src/Security/LoginFailureHandler.php new file mode 100644 index 0000000..8553ad9 --- /dev/null +++ b/src/Security/LoginFailureHandler.php @@ -0,0 +1,34 @@ +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); + } +} + + + diff --git a/src/Security/LoginSuccessHandler.php b/src/Security/LoginSuccessHandler.php new file mode 100644 index 0000000..dc293f9 --- /dev/null +++ b/src/Security/LoginSuccessHandler.php @@ -0,0 +1,38 @@ +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')); + } +} + diff --git a/src/Security/LogoutSuccessHandler.php b/src/Security/LogoutSuccessHandler.php new file mode 100644 index 0000000..525fbef --- /dev/null +++ b/src/Security/LogoutSuccessHandler.php @@ -0,0 +1,27 @@ +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('/'); + } +} + diff --git a/src/Service/ModpackZipService.php b/src/Service/ModpackZipService.php new file mode 100644 index 0000000..565d154 --- /dev/null +++ b/src/Service/ModpackZipService.php @@ -0,0 +1,37 @@ +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; + } +} diff --git a/src/Service/UploaderService.php b/src/Service/UploaderService.php new file mode 100644 index 0000000..4885cf5 --- /dev/null +++ b/src/Service/UploaderService.php @@ -0,0 +1,54 @@ +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é + } +} diff --git a/templates/administration/index.html.twig b/templates/administration/index.html.twig index cf32971..bb93302 100644 --- a/templates/administration/index.html.twig +++ b/templates/administration/index.html.twig @@ -2,8 +2,49 @@ {% block title %}Administration{% endblock %} -{% block body %} -
- -
+{% block stylesheets %} + {{ parent() }} + + +{% endblock %} + +{% block titlePage %} +
+
+

Administration

+
+
+{% endblock %} + +{% block main %} + {% endblock %} diff --git a/templates/administration/modpacks/edit.html.twig b/templates/administration/modpacks/edit.html.twig new file mode 100644 index 0000000..9a2409b --- /dev/null +++ b/templates/administration/modpacks/edit.html.twig @@ -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() }} + + + +{% endblock %} + +{% block titlePage %} +
+
+

Administration : Mods

+
+
+{% endblock %} + +{% block main %} +
+ {{ form_start(form, { attr: { class: 'bulle bulle-all-col form-wrapper' } }) }} + +
+ {{ form_label(form.version, "", { label_attr: { class: 'form-label' } }) }} + {{ form_widget(form.version, { attr: { class: 'form-input' } }) }} + {{ form_errors(form.version) }} +
+ +
+ + {{ form_errors(form.downloadable) }} +
+ +
+ {{ form_label(form.mods, "", { label_attr: { class: 'form-label' } }) }} +
+ + {% for mod in form.mods %} + +
+ + + + + +
+ {% endfor %} + +
+ {{ form_errors(form.mods) }} +
+ + + {{ form_end(form) }} +
+{% endblock %} diff --git a/templates/administration/modpacks/index.html.twig b/templates/administration/modpacks/index.html.twig new file mode 100644 index 0000000..e9bb243 --- /dev/null +++ b/templates/administration/modpacks/index.html.twig @@ -0,0 +1,50 @@ +{% extends 'base.html.twig' %} + +{% block title %}Administration{% endblock %} + +{% block stylesheets %} + {{ parent() }} + + +{% endblock %} + +{% block titlePage %} +
+
+

Administration : Modpacks

+
+
+{% endblock %} + +{% block main %} +
+ + {# ADD BUTTON #} + + + + + + +
+ Créer un modpack +
+
+ {% for modpack in modpacks %} +
+ +
{{ modpack.version }}
+
+
{{ modpack.numberofmods }} mod{% if modpack.numberofmods>=2 %}s{% endif %}
+
+ {% if modpack.downloadable %}Téléchargeable{% else %}Caché{% endif %} +
+ + Supprimer +
+ {% endfor %} +
+{% endblock %} diff --git a/templates/administration/mods/edit.html.twig b/templates/administration/mods/edit.html.twig new file mode 100644 index 0000000..413e69a --- /dev/null +++ b/templates/administration/mods/edit.html.twig @@ -0,0 +1,58 @@ +{% extends 'base.html.twig' %} + +{% block title %}Administration{% endblock %} + +{% block stylesheets %} + {{ parent() }} + + + +{% endblock %} + +{% block titlePage %} +
+
+

Administration : Mods

+
+
+{% endblock %} + +{% block main %} +
+ {{ form_start(form, { attr: { class: 'bulle bulle-all-col form-wrapper' } }) }} + +
+ {{ form_label(form.nom, "", { label_attr: { class: 'form-label' } }) }} + {{ form_widget(form.nom, { attr: { class: 'form-input' } }) }} + {{ form_errors(form.nom) }} +
+ +
+ {{ form_label(form.version, "", { label_attr: { class: 'form-label' } }) }} + {{ form_widget(form.version, { attr: { class: 'form-input' } }) }} + {{ form_errors(form.version) }} +
+ +
+ {{ form_label(form.uri, "", { label_attr: { class: 'form-label' } }) }} + {{ form_widget(form.uri, { attr: { class: 'form-file' } }) }} + {{ form_errors(form.uri) }} +
+ +
+ {{ 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) }} +
+ + + {{ form_end(form) }} +
+{% endblock %} diff --git a/templates/administration/mods/index.html.twig b/templates/administration/mods/index.html.twig new file mode 100644 index 0000000..7ab4be0 --- /dev/null +++ b/templates/administration/mods/index.html.twig @@ -0,0 +1,60 @@ +{% extends 'base.html.twig' %} + +{% block title %}Administration{% endblock %} + +{% block stylesheets %} + {{ parent() }} + + +{% endblock %} + +{% block titlePage %} +
+
+

Administration : Mods

+
+
+{% endblock %} + +{% block main %} +
+ + {# ADD BUTTON #} + + + + + + +
+ Ajouter un mod +
+
+ {% for mod in mods %} +
+ +
{{ mod.nom }}
+
+
Version : {{ mod.version }}
+ +
+ {% for dependance in mod.dependances %} + + {{ dependance.nom }} - {{ dependance.version }} + + {% endfor %} +
+ +
+ {% if mod.isUsed %}Utilisé{% else %}Non utilisé{% endif %} +
+ + Supprimer +
+ {% endfor %} +
+{% endblock %} diff --git a/templates/administration/users/edit.html.twig b/templates/administration/users/edit.html.twig new file mode 100644 index 0000000..02a7939 --- /dev/null +++ b/templates/administration/users/edit.html.twig @@ -0,0 +1,71 @@ +{% extends 'base.html.twig' %} + +{% block title %}Administration{% endblock %} + +{% block stylesheets %} + {{ parent() }} + + +{% endblock %} + +{% block titlePage %} +
+
+

Administration : Users

+
+
+{% endblock %} + +{% block main %} +
+ {{ form_start(form, { attr: { class: 'bulle bulle-all-col form-wrapper' } }) }} + +
+ + {# Colonne gauche : pseudo + password + joueur #} +
+
+ {{ form_label(form.pseudo, "", { label_attr: { class: 'form-label' } }) }} + {{ form_widget(form.pseudo, { attr: { class: 'form-input' } }) }} + {{ form_errors(form.pseudo) }} +
+ +
+ {{ form_label(form.password, "", { label_attr: { class: 'form-label' } }) }} + {{ form_widget(form.password, { attr: { class: 'form-input' } }) }} + {{ form_errors(form.password) }} +
+ +
+ {{ form_label(form.joueur, "", { label_attr: { class: 'form-label' } }) }} + {{ form_widget(form.joueur, { attr: { class: 'form-input form-select' } }) }} + {{ form_errors(form.joueur) }} +
+
+ + {# Colonne droite : rôles + theme #} +
+
+ {{ form_label(form.roles, "", { label_attr: { class: 'form-label' } }) }} +
+ {% for role in form.roles %} + + {% endfor %} +
+ {{ form_errors(form.roles) }} +
+ +
+ +
+ + + {{ form_end(form) }} +
+{% endblock %} diff --git a/templates/administration/users/index.html.twig b/templates/administration/users/index.html.twig new file mode 100644 index 0000000..930aed1 --- /dev/null +++ b/templates/administration/users/index.html.twig @@ -0,0 +1,73 @@ +{% extends 'base.html.twig' %} + +{% block title %}Administration{% endblock %} + +{% block stylesheets %} + {{ parent() }} + + +{% endblock %} + +{% block titlePage %} +
+
+

Administration : Users

+
+
+{% endblock %} + +{% block main %} + +{% endblock %} diff --git a/templates/base.html.twig b/templates/base.html.twig index ff96e31..b8439df 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -2,8 +2,9 @@ - {% block title %}CraftWorld{% endblock %} + {% block title %}Ploush{% endblock %} + {# Anti-flash - DOIT être avant tout le reste #}