public -> src) pour atteindre la racine src/. require dirname(__DIR__, 2) . '/app/Core/Autoloader.php'; Autoloader::register(); // En-tetes de securite poses tot, valables sur toute reponse y compris une 500. header('X-Content-Type-Options: nosniff'); header('X-Robots-Tag: noindex, nofollow'); $config = new Config(); date_default_timezone_set($config->timezone()); // Requete + middleware CORS construits AVANT le try : ils ne dependent que de la // config et des globales, et doivent rester accessibles dans le catch pour decorer // la reponse 500 d'une requete /api/ cross-origin (sans quoi le navigateur de la // borne ne peut pas lire le corps de l'erreur). $request = Request::fromGlobals(); $cors = new Cors($config->get('CORS_ALLOWED_ORIGIN', '') ?? ''); try { // Acces BDD paresseux : la connexion n'est ouverte qu'au premier query(), // donc la home back-office reste servie meme base indisponible. $database = new Database($config); // Demarre la session du vhost admin avant le dispatch (effet de bord global, // hors du Core stateless). Les controleurs y rattachent leur SessionManager. (new SessionManager($config))->start(); $router = new Router($config, $database); $router->add('GET', '/', [HomeController::class, 'index']); $router->add('GET', '/api/health', [HealthController::class, 'index']); // Authentification back-office (mlt.md section 12). Le docroot du vhost admin // etant src/public/admin, le Router voit "/login" (pas de prefixe "/admin"). $router->add('GET', '/login', [AuthController::class, 'showLogin']); $router->add('POST', '/login', [AuthController::class, 'login']); $router->add('POST', '/logout', [AuthController::class, 'logout']); $router->add('GET', '/forgot_password', [PasswordResetController::class, 'showRequest']); $router->add('POST', '/forgot_password', [PasswordResetController::class, 'submitRequest']); $router->add('GET', '/reset_password', [PasswordResetController::class, 'showConfirm']); $router->add('POST', '/reset_password', [PasswordResetController::class, 'submitConfirm']); // RBAC : identite + permissions de la session courante (gardee par SessionGuard). $router->add('GET', '/api/me', [MeController::class, 'show']); // Commandes borne (P4, domaine 7). API publique kiosk, ANONYME (pas de session) : // creation en pending_payment puis encaissement (paid + decrement stock RG-T20). // Idempotente sur idempotency_key (anti double-clic / retry reseau). {number} = // un seul segment (numero K+id), pas de collision avec un sous-chemin. $router->add('POST', '/api/orders', [OrderController::class, 'create']); $router->add('POST', '/api/orders/{number}/pay', [OrderController::class, 'pay']); // Suivi public du statut d'une commande par son numero (lecture seule, anonyme). $router->add('GET', '/api/orders/{number}', [OrderController::class, 'show']); // Lecture catalogue borne (P4, docs/api/conventions.md section 5.2). API publique // kiosk, ANONYME : la borne consulte sans session. Lecture seule ; ne sert que le // commandable (categories actives, produits disponibles en categorie active). // {id} = un seul segment ; /api/products (collection) et /api/products/{id} // (unitaire) ne se chevauchent pas. $router->add('GET', '/api/categories', [CatalogueController::class, 'categories']); $router->add('GET', '/api/products', [CatalogueController::class, 'products']); $router->add('GET', '/api/products/{id}', [CatalogueController::class, 'product']); // Menus composes : liste legere + detail avec slots (B1 burger impose, B2 Normal/Maxi). $router->add('GET', '/api/menus', [CatalogueController::class, 'menus']); $router->add('GET', '/api/menus/{id}', [CatalogueController::class, 'menu']); // Allergenes INCO (info generale, 14 categories). La borne garde son JSON statique // (descriptions riches) ; l'endpoint sert d'autres consommateurs eventuels. $router->add('GET', '/api/allergens', [CatalogueController::class, 'allergens']); // Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard. $router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']); // Tableau de bord statistiques (stats.read) : landing du role manager. KPIs // catalogue + sante stock (RG-T21) ; KPIs de vente avec les commandes (P4). $router->add('GET', '/admin/stats', [StatsController::class, 'index']); // Commandes (P4, order.read) : liste lecture seule du domaine commande. $router->add('GET', '/admin/orders', [OrderAdminController::class, 'index']); // Gestion des comptes (mlt domaine 10). user.read (liste) ; user.create/update/ // deactivate. TOUTES les mutations = PIN equipier + audit (RG-T13/14). {id} = un // seul segment (pas de collision avec /edit, /deactivate, /reset-pin, /erase). $router->add('GET', '/admin/users', [UserController::class, 'index']); $router->add('GET', '/admin/users/new', [UserController::class, 'create']); $router->add('POST', '/admin/users', [UserController::class, 'store']); $router->add('GET', '/admin/users/{id}/edit', [UserController::class, 'edit']); $router->add('POST', '/admin/users/{id}', [UserController::class, 'update']); $router->add('GET', '/admin/users/{id}/deactivate', [UserController::class, 'confirmDeactivate']); $router->add('POST', '/admin/users/{id}/deactivate', [UserController::class, 'deactivate']); $router->add('GET', '/admin/users/{id}/reset-pin', [UserController::class, 'confirmResetPin']); $router->add('POST', '/admin/users/{id}/reset-pin', [UserController::class, 'resetPin']); $router->add('GET', '/admin/users/{id}/erase', [UserController::class, 'confirmErase']); $router->add('POST', '/admin/users/{id}/erase', [UserController::class, 'erase']); // RBAC (mlt 10.4, role.manage) : matrice roles x permissions + roles custom. // Toute mutation = PIN equipier + audit (details = diff de permissions, RG-6). $router->add('GET', '/admin/roles', [RoleController::class, 'index']); $router->add('GET', '/admin/roles/new', [RoleController::class, 'create']); $router->add('POST', '/admin/roles', [RoleController::class, 'store']); $router->add('GET', '/admin/roles/{id}/edit', [RoleController::class, 'edit']); $router->add('POST', '/admin/roles/{id}', [RoleController::class, 'update']); // CRUD Categories (permission category.manage). Pas de suppression dure : toggle is_active. $router->add('GET', '/admin/categories', [CategoryController::class, 'index']); $router->add('GET', '/admin/categories/new', [CategoryController::class, 'create']); $router->add('POST', '/admin/categories', [CategoryController::class, 'store']); $router->add('GET', '/admin/categories/{id}/edit', [CategoryController::class, 'edit']); $router->add('POST', '/admin/categories/{id}', [CategoryController::class, 'update']); $router->add('POST', '/admin/categories/{id}/toggle', [CategoryController::class, 'toggle']); // Profil self-service : definition du PIN d'action sensible (RG-T13). $router->add('GET', '/admin/profile/pin', [ProfileController::class, 'showPin']); $router->add('POST', '/admin/profile/pin', [ProfileController::class, 'updatePin']); // Mention d'information RGPD (Cr 3.d.2) : traitement des donnees personnelles du // personnel. Accessible a tout utilisateur authentifie (aucune permission requise). $router->add('GET', '/admin/privacy', [PrivacyController::class, 'index']); // CRUD Produits (product.read/create/update/delete). PIN equipier + audit sur // changement prix/TVA (update) et suppression (delete). $router->add('GET', '/admin/products', [ProductController::class, 'index']); $router->add('GET', '/admin/products/new', [ProductController::class, 'create']); $router->add('POST', '/admin/products', [ProductController::class, 'store']); $router->add('GET', '/admin/products/{id}/edit', [ProductController::class, 'edit']); $router->add('POST', '/admin/products/{id}', [ProductController::class, 'update']); $router->add('GET', '/admin/products/{id}/delete', [ProductController::class, 'confirmDelete']); $router->add('POST', '/admin/products/{id}/delete', [ProductController::class, 'destroy']); // Editeur de recette (composition product_ingredient). Permission ingredient.manage // (composition), distincte du CRUD produit ; sans PIN. Debloque la dispo calculee // RG-T21 et ferme la dette #27 (trace cascade a la suppression). $router->add('GET', '/admin/products/{id}/recipe', [ProductController::class, 'recipeForm']); $router->add('POST', '/admin/products/{id}/recipe', [ProductController::class, 'saveRecipe']); // CRUD Menus (menu.read/create/update/delete). Menu compose = burger de base + // slots (menu_slot / menu_slot_option). PIN equipier + audit sur suppression // (mlt 8.6) ; create/update sans PIN. {id} = un seul segment, pas de collision // avec /toggle ni /delete. $router->add('GET', '/admin/menus', [MenuController::class, 'index']); $router->add('GET', '/admin/menus/new', [MenuController::class, 'create']); $router->add('POST', '/admin/menus', [MenuController::class, 'store']); $router->add('GET', '/admin/menus/{id}/edit', [MenuController::class, 'edit']); $router->add('POST', '/admin/menus/{id}', [MenuController::class, 'update']); $router->add('POST', '/admin/menus/{id}/toggle', [MenuController::class, 'toggle']); $router->add('GET', '/admin/menus/{id}/delete', [MenuController::class, 'confirmDelete']); $router->add('POST', '/admin/menus/{id}/delete', [MenuController::class, 'destroy']); // Stock / Ingredients (P3, mlt 8.8 + domaine 9). Permissions par operation : // stock.read (liste/mouvements, tous roles) ; ingredient.manage (CRUD, sans PIN) ; // stock.manage (reappro, sans PIN) ; stock.count (inventaire, + PIN). Pas d'audit_log // (RG-T14) : l'attribution passe par stock_movement.user_id. $router->add('GET', '/admin/ingredients', [IngredientController::class, 'index']); $router->add('GET', '/admin/ingredients/new', [IngredientController::class, 'create']); $router->add('POST', '/admin/ingredients', [IngredientController::class, 'store']); $router->add('GET', '/admin/ingredients/{id}/edit', [IngredientController::class, 'edit']); $router->add('POST', '/admin/ingredients/{id}', [IngredientController::class, 'update']); $router->add('POST', '/admin/ingredients/{id}/toggle', [IngredientController::class, 'toggle']); $router->add('GET', '/admin/ingredients/{id}/delete', [IngredientController::class, 'confirmDelete']); $router->add('POST', '/admin/ingredients/{id}/delete', [IngredientController::class, 'destroy']); $router->add('GET', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restockForm']); $router->add('POST', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restock']); $router->add('GET', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventoryForm']); $router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']); $router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']); // CORS (docs/api/conventions.md section 10) : preflight OPTIONS traite AVANT le // routeur (pas de route OPTIONS) ; sinon dispatch puis decoration de la reponse. // Scope /api/ + origine exacte geres par le middleware (fail-closed). $request et // $cors sont construits hors du try pour que le catch puisse decorer aussi le 500. $preflight = $cors->preflightResponse($request); if ($preflight !== null) { $preflight->send(); } else { $response = $router->dispatch($request); $cors->applyTo($request, $response); $response->send(); } } catch (Throwable $exception) { // En debug on remonte le message pour iterer ; en prod, reponse generique // pour ne rien divulguer de la pile interne (information disclosure). $payload = $config->isDebug() ? ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => $exception->getMessage()]] : ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => 'Internal server error']]; // Decore aussi la 500 : une requete /api/ cross-origin (ex. BDD indisponible) // doit rester lisible par le navigateur de la borne (RG enveloppe d'erreur). $errorResponse = (new Response())->json($payload, 500); $cors->applyTo($request, $errorResponse); $errorResponse->send(); }