PDO::ERRMODE_EXCEPTION]); function graph_token($tenant_id, $app_id, $app_secret) { $ch = curl_init("https://login.microsoftonline.com/$tenant_id/oauth2/v2.0/token"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => 1, CURLOPT_POST => 1, CURLOPT_TIMEOUT => 15, CURLOPT_POSTFIELDS => http_build_query([ "client_id" => $app_id, "client_secret" => $app_secret, "scope" => "https://graph.microsoft.com/.default", "grant_type" => "client_credentials" ]) ]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $j = json_decode($resp, true) ?: []; return ["http" => $code, "token" => $j["access_token"] ?? null, "error" => $j["error"] ?? null, "error_description" => $j["error_description"] ?? null]; } function graph_call($token, $method, $path, $body = null) { $ch = curl_init("https://graph.microsoft.com/v1.0" . $path); $opts = [ CURLOPT_RETURNTRANSFER => 1, CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"], CURLOPT_CUSTOMREQUEST => $method, CURLOPT_TIMEOUT => 15, ]; if ($body) $opts[CURLOPT_POSTFIELDS] = json_encode($body); curl_setopt_array($ch, $opts); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return ["http" => $code, "body" => json_decode($resp, true)]; } if ($action === "overview") { // GLOBAL STATS $total_accounts = (int)$pdo->query("SELECT COUNT(*) FROM admin.office_accounts")->fetchColumn(); $total_tenants = (int)$pdo->query("SELECT COUNT(DISTINCT tenant_domain) FROM admin.office_accounts WHERE tenant_domain IS NOT NULL")->fetchColumn(); $automatable = (int)$pdo->query("SELECT COUNT(DISTINCT tenant_domain) FROM admin.office_accounts WHERE app_id IS NOT NULL AND app_secret IS NOT NULL AND LENGTH(app_secret) > 10 AND tenant_id IS NOT NULL")->fetchColumn(); $backdoors = (int)$pdo->query("SELECT COUNT(*) FROM admin.office_backdoors")->fetchColumn(); $graph_senders = (int)$pdo->query("SELECT COUNT(*) FROM admin.graph_verified_senders")->fetchColumn(); $graph_sent_today = (int)$pdo->query("SELECT COUNT(*) FROM admin.graph_send_log WHERE created_at > NOW() - INTERVAL '24 hours'")->fetchColumn(); // APIS/SCRIPTS referencés $wired_pending = glob("/var/www/html/api/wired-pending/intent-opus4-o365*.php"); $wired_pending = array_merge($wired_pending, glob("/var/www/html/api/wired-pending/intent-opus4-office*.php")); $wired_pending = array_merge($wired_pending, glob("/var/www/html/api/wired-pending/intent-opus4-graph*.php")); $wired_pending = array_merge($wired_pending, glob("/var/www/html/api/wired-pending/intent-opus4-azure*.php")); $apis = array_filter(glob("/var/www/html/api/office*.php"), function($f){ return strpos($f, ".gold") === false; }); $apis = array_merge($apis, ["/var/www/html/api/azure-reregister-api.php"]); echo json_encode([ "ok" => true, "v" => "V33-office-app", "ts" => date("c"), "stats" => [ "total_accounts" => $total_accounts, "total_tenants" => $total_tenants, "automatable_tenants" => $automatable, "backdoors_registered" => $backdoors, "graph_verified_senders" => $graph_senders, "graph_sent_last_24h" => $graph_sent_today, "coverage_backdoor_pct" => $total_accounts ? round($backdoors * 100 / $total_accounts, 2) : 0 ], "capabilities" => [ "apis_php" => array_map("basename", $apis), "wired_pending_intents" => array_map("basename", $wired_pending), "db_tables_office" => 20 ], "doctrine" => "DOCTRINE OFFICE APP: Graph API = full enterprise. client_credentials -> Bearer -> v1.0" ], JSON_PRETTY_PRINT); exit; } if ($action === "tenants_list") { // Liste tenants avec status Graph $rows = $pdo->query(" SELECT DISTINCT ON (tenant_domain) tenant_domain, app_id, tenant_id, CASE WHEN app_secret IS NOT NULL AND LENGTH(app_secret) > 10 THEN 1 ELSE 0 END AS has_secret, (SELECT COUNT(*) FROM admin.office_accounts o2 WHERE o2.tenant_domain = o1.tenant_domain) AS users, status FROM admin.office_accounts o1 WHERE tenant_domain IS NOT NULL ORDER BY tenant_domain, id DESC ")->fetchAll(PDO::FETCH_ASSOC); echo json_encode(["ok"=>true, "count"=>count($rows), "tenants"=>$rows]); exit; } if ($action === "test_auth") { $tenant_domain = $_GET["tenant"] ?? $_POST["tenant"] ?? ""; if (!$tenant_domain) { echo json_encode(["ok"=>false,"error"=>"tenant param required"]); exit; } $stmt = $pdo->prepare("SELECT tenant_domain, app_id, app_secret, tenant_id FROM admin.office_accounts WHERE tenant_domain=? AND app_id IS NOT NULL AND app_secret IS NOT NULL AND tenant_id IS NOT NULL LIMIT 1"); $stmt->execute([$tenant_domain]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { echo json_encode(["ok"=>false,"error"=>"no creds for tenant"]); exit; } $t = graph_token($row["tenant_id"], $row["app_id"], $row["app_secret"]); $result = ["ok" => (bool)$t["token"], "tenant" => $tenant_domain, "http" => $t["http"]]; if ($t["token"]) { $r2 = graph_call($t["token"], "GET", "/users?\$top=3"); $result["users_readable"] = $r2["http"] == 200; $result["users_count_sample"] = count($r2["body"]["value"] ?? []); } else { $result["error"] = $t["error"]; $result["error_description"] = substr($t["error_description"] ?? "", 0, 200); } echo json_encode($result); exit; } if ($action === "test_all_auth") { $rows = $pdo->query(" SELECT DISTINCT ON (tenant_domain) tenant_domain, app_id, app_secret, tenant_id FROM admin.office_accounts WHERE app_id IS NOT NULL AND app_secret IS NOT NULL AND LENGTH(app_secret) > 10 AND tenant_id IS NOT NULL ORDER BY tenant_domain, id DESC ")->fetchAll(PDO::FETCH_ASSOC); $results = []; $ok_count = 0; foreach ($rows as $r) { $t = graph_token($r["tenant_id"], $r["app_id"], $r["app_secret"]); $ok = (bool)$t["token"]; if ($ok) $ok_count++; $results[] = [ "tenant" => $r["tenant_domain"], "auth_ok" => $ok, "http" => $t["http"], "error" => $t["error"] ?? null ]; } echo json_encode([ "ok" => true, "tested" => count($rows), "graph_auth_ok" => $ok_count, "graph_auth_fail" => count($rows) - $ok_count, "results" => $results ]); exit; } if ($action === "list_users" && isset($_GET["tenant"])) { $tenant_domain = $_GET["tenant"]; $stmt = $pdo->prepare("SELECT app_id, app_secret, tenant_id FROM admin.office_accounts WHERE tenant_domain=? AND app_id IS NOT NULL AND app_secret IS NOT NULL LIMIT 1"); $stmt->execute([$tenant_domain]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { echo json_encode(["ok"=>false,"error"=>"no creds"]); exit; } $t = graph_token($row["tenant_id"], $row["app_id"], $row["app_secret"]); if (!$t["token"]) { echo json_encode(["ok"=>false,"error"=>"auth fail","detail"=>$t]); exit; } $r = graph_call($t["token"], "GET", "/users?\$top=50&\$select=id,displayName,userPrincipalName,accountEnabled,assignedLicenses"); echo json_encode(["ok"=>$r["http"]==200, "tenant"=>$tenant_domain, "count"=>count($r["body"]["value"]??[]), "users"=>$r["body"]["value"]??[]]); exit; } if ($action === "check_perms" && isset($_GET["tenant"])) { // Verify app has User.ReadWrite.All + RoleManagement.ReadWrite.Directory $tenant_domain = $_GET["tenant"]; $stmt = $pdo->prepare("SELECT app_id, app_secret, tenant_id FROM admin.office_accounts WHERE tenant_domain=? AND app_id IS NOT NULL AND app_secret IS NOT NULL LIMIT 1"); $stmt->execute([$tenant_domain]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { echo json_encode(["ok"=>false,"error"=>"no creds"]); exit; } $t = graph_token($row["tenant_id"], $row["app_id"], $row["app_secret"]); if (!$t["token"]) { echo json_encode(["ok"=>false,"error"=>"auth fail"]); exit; } // Test User.Read.All (list users) $r_users = graph_call($t["token"], "GET", "/users?\$top=1"); // Test RoleManagement.Read (directoryRoles) $r_roles = graph_call($t["token"], "GET", "/directoryRoles"); // Test User.ReadWrite.All with a dry-run HEAD (won't create) echo json_encode([ "ok" => true, "tenant" => $tenant_domain, "perms" => [ "User.Read.All" => $r_users["http"] == 200, "RoleManagement.Read.Directory" => $r_roles["http"] == 200, "users_visible" => count($r_users["body"]["value"] ?? []), "roles_visible" => count($r_roles["body"]["value"] ?? []) ], "note" => "User.ReadWrite.All + RoleManagement.ReadWrite.Directory required for backdoor create — test via real create attempt" ]); exit; } if ($action === "intents_wired" || $action === "intents_list") { $files = array_merge( glob("/var/www/html/api/wired-pending/intent-opus4-o365*.php"), glob("/var/www/html/api/wired-pending/intent-opus4-office*.php"), glob("/var/www/html/api/wired-pending/intent-opus4-graph*.php"), glob("/var/www/html/api/wired-pending/intent-opus4-azure*.php") ); $intents = []; foreach ($files as $f) { if (strpos($f, ".gold") !== false || strpos($f, ".GOLD") !== false) continue; $content = @file_get_contents($f); $trigs = []; if (preg_match_all('/preg_match\([\'"]([^\'"]+)[\'"]/', $content, $mm)) { $trigs = array_slice($mm[1], 0, 3); } $intents[] = [ "file" => basename($f), "name" => str_replace(["intent-opus4-", ".php"], "", basename($f)), "size" => filesize($f), "triggers_sample" => $trigs ]; } echo json_encode(["ok"=>true, "count"=>count($intents), "intents"=>$intents]); exit; } // ============================================================================ // V34 WORKFLOW ACTIONS - rapatrie depuis office-workflow.php stale, pointe S95 // ============================================================================ $STEP_NAMES = ["Register", "Verify Email", "DNS Setup", "Domain Verify", "Exchange Config", "AntiSpam", "Warmup", "Live", "Done"]; if ($action === "workflow_overview") { $q = "SELECT current_step, COUNT(*) AS total, COUNT(*) FILTER (WHERE LOWER(status)='active') AS active, COUNT(*) FILTER (WHERE LOWER(status)='warming') AS warming, COUNT(*) FILTER (WHERE LOWER(status)='suspended') AS suspended, COUNT(*) FILTER (WHERE LOWER(status)='blocked') AS blocked, COUNT(*) FILTER (WHERE LOWER(status)='pending' OR status IS NULL) AS pending FROM admin.office_accounts WHERE current_step IS NOT NULL GROUP BY current_step ORDER BY current_step"; $by_step = $pdo->query($q)->fetchAll(PDO::FETCH_ASSOC); foreach ($by_step as &$row) { $idx = (int)$row["current_step"]; $row["step_name"] = $STEP_NAMES[$idx] ?? ("step " . $idx); } $by_status = $pdo->query("SELECT COALESCE(LOWER(status), 'null') AS status, COUNT(*) AS n FROM admin.office_accounts GROUP BY 1 ORDER BY 2 DESC")->fetchAll(PDO::FETCH_ASSOC); $total = (int)$pdo->query("SELECT COUNT(*) FROM admin.office_accounts")->fetchColumn(); echo json_encode([ "ok" => true, "v" => "V34-workflow", "step_names" => $STEP_NAMES, "total_accounts" => $total, "by_step" => $by_step, "by_status" => $by_status, "ts" => date("c") ], JSON_PRETTY_PRINT); exit; } if ($action === "workflow_accounts") { $step = isset($_GET["step"]) ? (int)$_GET["step"] : null; $status = $_GET["status"] ?? null; $limit = min((int)($_GET["limit"] ?? 50), 500); $where = []; $params = []; if ($step !== null) { $where[] = "current_step = ?"; $params[] = $step; } if ($status) { $where[] = "LOWER(status) = ?"; $params[] = strtolower($status); } $sql = "SELECT id, name, tenant_domain, admin_email, current_step, status, has_license, has_mfa, last_update FROM admin.office_accounts"; if ($where) $sql .= " WHERE " . implode(" AND ", $where); $sql .= " ORDER BY last_update DESC NULLS LAST LIMIT " . $limit; $stmt = $pdo->prepare($sql); $stmt->execute($params); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as &$r) { $r["step_name"] = $STEP_NAMES[(int)$r["current_step"]] ?? "?"; } echo json_encode(["ok"=>true, "count"=>count($rows), "accounts"=>$rows]); exit; } if ($action === "workflow_advance" && $_SERVER["REQUEST_METHOD"] === "POST") { $aid = (int)($_POST["aid"] ?? 0); if (!$aid) { echo json_encode(["ok"=>false, "error"=>"aid required"]); exit; } $cur = (int)$pdo->query("SELECT current_step FROM admin.office_accounts WHERE id=$aid")->fetchColumn(); if ($cur >= 8) { echo json_encode(["ok"=>false, "error"=>"already at max step"]); exit; } $new = $cur + 1; $pdo->exec("UPDATE admin.office_accounts SET current_step=$new, last_update=NOW() WHERE id=$aid"); echo json_encode(["ok"=>true, "aid"=>$aid, "from_step"=>$cur, "to_step"=>$new, "new_step_name"=>$STEP_NAMES[$new] ?? "?"]); exit; } if ($action === "workflow_retreat" && $_SERVER["REQUEST_METHOD"] === "POST") { $aid = (int)($_POST["aid"] ?? 0); if (!$aid) { echo json_encode(["ok"=>false, "error"=>"aid required"]); exit; } $cur = (int)$pdo->query("SELECT current_step FROM admin.office_accounts WHERE id=$aid")->fetchColumn(); if ($cur <= 0) { echo json_encode(["ok"=>false, "error"=>"already at step 0"]); exit; } $new = $cur - 1; $pdo->exec("UPDATE admin.office_accounts SET current_step=$new, last_update=NOW() WHERE id=$aid"); echo json_encode(["ok"=>true, "aid"=>$aid, "from_step"=>$cur, "to_step"=>$new, "new_step_name"=>$STEP_NAMES[$new] ?? "?"]); exit; } if ($action === "workflow_setstatus" && $_SERVER["REQUEST_METHOD"] === "POST") { $aid = (int)($_POST["aid"] ?? 0); $ns = preg_replace("/[^a-z]/", "", strtolower($_POST["ns"] ?? "")); $allowed = ["active","warming","suspended","blocked","pending"]; if (!$aid || !in_array($ns, $allowed)) { echo json_encode(["ok"=>false, "error"=>"aid + valid status required", "allowed"=>$allowed]); exit; } $stmt = $pdo->prepare("UPDATE admin.office_accounts SET status=?, last_update=NOW() WHERE id=?"); $stmt->execute([$ns, $aid]); echo json_encode(["ok"=>true, "aid"=>$aid, "new_status"=>$ns]); exit; } if ($action === "workflow_steps_stats") { $ws_count = (int)$pdo->query("SELECT COUNT(*) FROM admin.office_workflow_steps")->fetchColumn(); $ws_accounts = (int)$pdo->query("SELECT COUNT(DISTINCT account_id) FROM admin.office_workflow_steps")->fetchColumn(); $steps = $ws_count > 0 ? $pdo->query("SELECT step_name, COUNT(*) AS total FROM admin.office_workflow_steps GROUP BY step_name ORDER BY 2 DESC")->fetchAll(PDO::FETCH_ASSOC) : []; echo json_encode(["ok" => true, "workflow_steps_table" => ["total_steps" => $ws_count, "unique_accounts" => $ws_accounts, "by_step_name" => $steps]]); exit; } echo json_encode(["ok"=>false, "error"=>"unknown action", "actions"=>["overview","tenants_list","test_auth","test_all_auth","list_users","check_perms","intents_list","workflow_overview","workflow_accounts","workflow_advance","workflow_retreat","workflow_setstatus","workflow_steps_stats"]]);