New MSP tool — single-assessor consult intake. Stack: PHP + MySQL on the IX cPanel host, gated by Cloudflare Access (only mike@azcomputerguru.com; app re-checks the Cf-Access-Authenticated-User-Email header). - app/questions.json — risk-ordered question framework (9 sections); each field tagged source=syncro/rmm/scan/ask so the consult asks only what a human knows and the post-meeting scan fills the technical reality. - app/index.php — wizard UI: Syncro phone lookup -> prefill, section rail with live progress, importance-colored question cards, in-meeting 365/Google consent links, review + export. - app/api.php — Syncro lookup-by-phone, save/load/list, consent-URL generation (reuses the read-only Security Investigator app bfbc12a4-...), HTML export. - app/schema.sql, config.sample.php, DEPLOY.md, README.md. Consent links let the client approve read-only 365/Google access during the consult so the audit scan runs afterward. Read-only by design (reads Syncro, generates consent; no tenant writes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
127 lines
6.7 KiB
PHP
127 lines
6.7 KiB
PHP
<?php
|
|
// Backend for the ACG security assessment intake. JSON API behind Cloudflare Access.
|
|
require __DIR__ . '/config.php';
|
|
header('Content-Type: application/json');
|
|
|
|
$email = $_SERVER['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'] ?? '';
|
|
if (ALLOWED_EMAIL !== '' && strcasecmp($email, ALLOWED_EMAIL) !== 0) {
|
|
http_response_code(403); echo json_encode(['error' => 'forbidden']); exit;
|
|
}
|
|
|
|
function db() {
|
|
static $pdo;
|
|
if (!$pdo) $pdo = new PDO('mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4', DB_USER, DB_PASS,
|
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
|
|
return $pdo;
|
|
}
|
|
function body() { return json_decode(file_get_contents('php://input'), true) ?: []; }
|
|
function out($x) { echo json_encode($x); exit; }
|
|
|
|
function syncro($path) {
|
|
$url = rtrim(SYNCRO_BASE, '/') . $path . (strpos($path, '?') === false ? '?' : '&') . 'api_key=' . SYNCRO_KEY;
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, CURLOPT_HTTPHEADER => ['Accept: application/json']]);
|
|
$raw = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
|
|
$raw = preg_replace('/[\x00-\x1F]/', ' ', $raw); // strip control chars Syncro leaks
|
|
return [$code, json_decode($raw, true)];
|
|
}
|
|
|
|
$action = $_GET['action'] ?? '';
|
|
|
|
// ---- Syncro client lookup by phone ----
|
|
if ($action === 'lookup') {
|
|
$phone = preg_replace('/\D/', '', body()['phone'] ?? '');
|
|
if (strlen($phone) < 7) out(['error' => 'phone too short']);
|
|
[$c, $res] = syncro('/customers?phone=' . urlencode($phone));
|
|
$cust = $res['customers'][0] ?? null;
|
|
if (!$cust) { [$c, $res] = syncro('/customers?query=' . urlencode($phone)); $cust = $res['customers'][0] ?? null; }
|
|
if (!$cust) out(['error' => 'no Syncro customer for that phone']);
|
|
$domain = '';
|
|
if (!empty($cust['email']) && strpos($cust['email'], '@') !== false) $domain = substr(strrchr($cust['email'], '@'), 1);
|
|
out(['prefill' => [
|
|
'business_name' => $cust['business_name'] ?: trim(($cust['firstname'] ?? '') . ' ' . ($cust['lastname'] ?? '')),
|
|
'syncro_customer_id' => $cust['id'] ?? '',
|
|
'address' => trim(($cust['address'] ?? '') . ' ' . ($cust['city'] ?? '') . ' ' . ($cust['state'] ?? '') . ' ' . ($cust['zip'] ?? '')),
|
|
'primary_contact' => trim(($cust['firstname'] ?? '') . ' ' . ($cust['lastname'] ?? '')),
|
|
'contact_email' => $cust['email'] ?? '',
|
|
'email_domains' => $domain ? [$domain] : [],
|
|
'tenant_domain' => $domain,
|
|
'assessment_date' => date('Y-m-d'),
|
|
], 'rmm' => null]);
|
|
}
|
|
|
|
// ---- Consent link generation ----
|
|
if ($action === 'consent') {
|
|
$b = body(); $provider = $b['provider'] ?? ''; $domain = trim($b['domain'] ?? '');
|
|
if ($provider === 'm365') {
|
|
if (!$domain) out(['error' => 'need tenant domain']);
|
|
// Admin-consent prompt for our read-only Security Investigator app (multi-tenant).
|
|
out(['url' => 'https://login.microsoftonline.com/' . rawurlencode($domain)
|
|
. '/adminconsent?client_id=' . M365_INVESTIGATOR_APP_ID
|
|
. '&redirect_uri=' . rawurlencode(CONSENT_REDIRECT)]);
|
|
}
|
|
if ($provider === 'google') {
|
|
if (GOOGLE_CLIENT_ID === '') out(['error' => 'Google client not configured yet']);
|
|
out(['url' => 'https://accounts.google.com/o/oauth2/v2/auth?client_id=' . rawurlencode(GOOGLE_CLIENT_ID)
|
|
. '&response_type=code&access_type=offline&prompt=consent'
|
|
. '&scope=' . rawurlencode(GOOGLE_SCOPES)
|
|
. '&redirect_uri=' . rawurlencode(GOOGLE_REDIRECT)
|
|
. ($domain ? '&hd=' . rawurlencode($domain) : '')]);
|
|
}
|
|
out(['error' => 'unknown provider']);
|
|
}
|
|
|
|
// ---- Save / upsert ----
|
|
if ($action === 'save') {
|
|
$b = body();
|
|
$data = json_encode($b['data'] ?? []);
|
|
$consent = json_encode($b['consent'] ?? []);
|
|
if (!empty($b['id'])) {
|
|
db()->prepare('UPDATE assessments SET phone=?, business_name=?, data=?, consent=?, updated=NOW() WHERE id=?')
|
|
->execute([$b['phone'] ?? '', $b['business_name'] ?? '', $data, $consent, $b['id']]);
|
|
out(['id' => (int)$b['id']]);
|
|
}
|
|
db()->prepare('INSERT INTO assessments (phone, business_name, data, consent, created, updated) VALUES (?,?,?,?,NOW(),NOW())')
|
|
->execute([$b['phone'] ?? '', $b['business_name'] ?? '', $data, $consent]);
|
|
out(['id' => (int)db()->lastInsertId()]);
|
|
}
|
|
|
|
// ---- Load ----
|
|
if ($action === 'load') {
|
|
$row = db()->prepare('SELECT data, consent FROM assessments WHERE id=?');
|
|
$row->execute([$_GET['id'] ?? 0]); $r = $row->fetch();
|
|
out($r ? ['data' => json_decode($r['data'], true), 'consent' => json_decode($r['consent'], true)] : ['error' => 'not found']);
|
|
}
|
|
|
|
// ---- List ----
|
|
if ($action === 'list') {
|
|
$rows = db()->query('SELECT id, phone, business_name, DATE_FORMAT(updated, "%Y-%m-%d %H:%i") updated FROM assessments ORDER BY updated DESC LIMIT 100')->fetchAll();
|
|
out(['items' => $rows]);
|
|
}
|
|
|
|
// ---- Export (HTML report-ready intake) ----
|
|
if ($action === 'export') {
|
|
$row = db()->prepare('SELECT * FROM assessments WHERE id=?'); $row->execute([$_GET['id'] ?? 0]); $r = $row->fetch();
|
|
if (!$r) { http_response_code(404); exit('not found'); }
|
|
$data = json_decode($r['data'], true); $consent = json_decode($r['consent'], true);
|
|
$q = json_decode(file_get_contents(__DIR__ . '/questions.json'), true);
|
|
header('Content-Type: text/html; charset=utf-8');
|
|
echo '<!doctype html><meta charset=utf-8><title>Assessment intake — ' . htmlspecialchars($r['business_name']) . '</title>';
|
|
echo '<style>body{font:14px/1.5 system-ui;max-width:820px;margin:30px auto;padding:0 16px;color:#0f172a}h1{margin-bottom:2px}h2{margin-top:26px;border-bottom:2px solid #e3e8ee;padding-bottom:4px}.r{display:grid;grid-template-columns:240px 1fr;gap:8px;padding:4px 0}.k{color:#64748b}@media print{body{margin:0}}</style>';
|
|
echo '<h1>' . htmlspecialchars($r['business_name'] ?: 'Client') . '</h1><div class=k>Security assessment intake · ' . $r['updated'] . '</div>';
|
|
foreach ($q['sections'] as $s) {
|
|
$rows = '';
|
|
foreach ($s['fields'] as $f) {
|
|
if (($f['type'] ?? '') === 'hidden') continue;
|
|
$v = $data[$f['id']] ?? '';
|
|
if (($f['type'] ?? '') === 'consent') { $cc = $consent[$f['provider']] ?? []; $v = !empty($cc['granted']) ? 'CONSENTED' : (!empty($cc['url']) ? 'link sent' : ''); }
|
|
if (is_array($v)) $v = implode(', ', $v);
|
|
if ($v !== '' && $v !== null) $rows .= '<div class=r><div class=k>' . htmlspecialchars($f['label']) . '</div><div>' . htmlspecialchars($v) . '</div></div>';
|
|
}
|
|
if ($rows) echo '<h2>' . htmlspecialchars($s['title']) . '</h2>' . $rows;
|
|
}
|
|
exit;
|
|
}
|
|
|
|
out(['error' => 'unknown action']);
|