<?php
// ═══════════════════════════════════════════════════════════════════════
//  AimsHub CMS — Security monitor  (portable across NTPPA / KPI / EAC)
//  • Logs suspicious events (login brute-force, honeypot, captcha, rate
//    limits, malicious URL probes) with the source IP.
//  • Enforces a manual IP blocklist (blocked IPs get a 403; logged-in
//    admins are always exempt so you can never lock yourself out).
//  • Emails the configured recipients on medium/high-severity events
//    (throttled to ~1 mail per IP per hour). Recipient is a CMS setting;
//    blank = alerts off.
//  Tables self-create on first use — no migration step required.
// ═══════════════════════════════════════════════════════════════════════

/** Create the two tables once per request (memoized). Returns false if the DB isn't ready. */
function security_tables(){
  static $ok = null;
  if ($ok !== null) return $ok;
  try {
    db()->exec("CREATE TABLE IF NOT EXISTS security_events (
      id INT AUTO_INCREMENT PRIMARY KEY,
      ip VARCHAR(45) NOT NULL,
      type VARCHAR(40) NOT NULL,
      severity TINYINT NOT NULL DEFAULT 1,
      path VARCHAR(255) DEFAULT NULL,
      method VARCHAR(8) DEFAULT NULL,
      ua VARCHAR(255) DEFAULT NULL,
      detail VARCHAR(255) DEFAULT NULL,
      created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
      INDEX ix_ip (ip), INDEX ix_created (created_at), INDEX ix_type (type)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    db()->exec("CREATE TABLE IF NOT EXISTS ip_blocklist (
      ip VARCHAR(45) PRIMARY KEY,
      reason VARCHAR(255) DEFAULT NULL,
      status VARCHAR(10) NOT NULL DEFAULT 'active',
      blocked_by VARCHAR(120) DEFAULT NULL,
      blocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
      released_by VARCHAR(120) DEFAULT NULL,
      released_at TIMESTAMP NULL DEFAULT NULL,
      hits INT NOT NULL DEFAULT 0
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    $ok = true;
  } catch (\Throwable $e) { $ok = false; }
  return $ok;
}

/** Record a security event, then maybe fire an alert. Never throws. */
function security_log($type, $severity = 1, $detail = ''){
  if (!security_tables()) return;
  $ip   = client_ip();
  $path = substr((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), 0, 255);
  $meth = substr((string) ($_SERVER['REQUEST_METHOD'] ?? ''), 0, 8);
  $ua   = substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255);
  try {
    db()->prepare('INSERT INTO security_events(ip,type,severity,path,method,ua,detail) VALUES(?,?,?,?,?,?,?)')
      ->execute([$ip, substr((string)$type,0,40), (int)$severity, $path, $meth, $ua, substr((string)$detail,0,255)]);
    // keep the table from growing without bound (90-day retention)
    if (mt_rand(1, 50) === 1) { try { db()->exec("DELETE FROM security_events WHERE created_at < (NOW() - INTERVAL 90 DAY)"); } catch (\Throwable $e) {} }
  } catch (\Throwable $e) { return; }
  if ((int)$severity >= 2) security_maybe_alert($ip, (int)$severity, (string)$type, (string)$detail);
}

/** Is this IP actively blocked? (Cheap PK lookup.) */
function security_ip_blocked($ip){
  if (!security_tables()) return false;
  try {
    $st = db()->prepare("SELECT 1 FROM ip_blocklist WHERE ip=? AND status='active' LIMIT 1");
    $st->execute([$ip]);
    return (bool) $st->fetchColumn();
  } catch (\Throwable $e) { return false; }
}

/** Runs on every request (from bootstrap). Enforces blocklist + flags URL probes. */
function security_boot(){
  if (PHP_SAPI === 'cli') return;
  $ip = client_ip();

  // 1) Blocklist enforcement — but NEVER lock out an authenticated admin,
  //    so a mistaken self-block is always recoverable from the admin.
  $isAdmin = !empty($_SESSION['uid']);
  if (!$isAdmin && $ip !== '' && security_ip_blocked($ip)) {
    try { db()->prepare("UPDATE ip_blocklist SET hits=hits+1 WHERE ip=?")->execute([$ip]); } catch (\Throwable $e) {}
    security_log('blocked_hit', 1, 'request while blocked');
    http_response_code(403);
    header('Content-Type: text/html; charset=utf-8');
    header('Retry-After: 3600');
    echo '<!doctype html><meta charset="utf-8"><title>Access blocked</title>'
       . '<div style="font:16px/1.6 system-ui,sans-serif;max-width:520px;margin:14vh auto;padding:0 24px;text-align:center">'
       . '<h1 style="font-size:22px">Access temporarily blocked</h1>'
       . '<p style="color:#555">Your network address has been blocked from this site. '
       . 'If you believe this is a mistake, please contact us and mention your IP address: <code>' . e($ip) . '</code>.</p></div>';
    exit;
  }

  // 2) Passive probe detection — clearly-malicious patterns that never occur
  //    in a legit route. Logged (severity 2) but NOT auto-blocked (manual policy).
  $uri = strtolower(rawurldecode((string) ($_SERVER['REQUEST_URI'] ?? '')));
  static $sigs = ['../', '..\\', '/.env', '/.git', '/.aws', 'wp-login', 'wp-admin', '/phpmyadmin',
    '/vendor/', 'eval(', 'base64_decode', 'union select', 'select ', '<script', 'onerror=',
    '/etc/passwd', 'cmd=', 'shell_exec', 'phpinfo', '.php~', '%00'];
  foreach ($sigs as $s) {
    if (strpos($uri, $s) !== false) { security_log('probe', 2, 'pattern: ' . $s); break; }
  }
}

/** Recipient list for alerts (CMS setting; blank = alerts off). */
function security_alert_email(){ return trim((string) setting('security_alert_email', '')); }

/** Email the alert, throttled to ~1 per IP per hour. Uses mail(); silent on failure. */
function security_maybe_alert($ip, $severity, $type, $detail){
  $to = security_alert_email();
  if ($to === '') return;                    // alerts disabled until an address is set
  // throttle: if we already logged an alert-worthy event from this IP in the last
  // hour (before this one), assume it was already emailed — don't re-send.
  try {
    $st = db()->prepare("SELECT COUNT(*) FROM security_events WHERE ip=? AND severity>=2 AND created_at > (NOW() - INTERVAL 1 HOUR)");
    $st->execute([$ip]);
    if ((int) $st->fetchColumn() > 1) return;
  } catch (\Throwable $e) {}

  $site = (string) setting('site_name', 'Your site');
  $host = (string) ($_SERVER['HTTP_HOST'] ?? '');
  $adminUrl = (function_exists('base_url') ? rtrim(base_url(''), '/') : '') . '/admin/?p=security';
  $subj = "[$site] Security alert: $type from $ip";
  $lines = [
    "A security event was recorded on $site.",
    "", "  Type:     $type", "  Severity: " . ($severity >= 3 ? 'HIGH' : 'medium'),
    "  IP:       $ip", "  Detail:   $detail",
    "  Path:     " . (string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH),
    "  Time:     " . date('Y-m-d H:i:s T'),
    "", "Review this IP and block/release it in the CMS:",
    "  https://$host" . $adminUrl,
    "", "— AimsHub CMS security monitor",
  ];
  $headers = 'From: security@' . preg_replace('/^www\./', '', $host) . "\r\n"
           . "Content-Type: text/plain; charset=utf-8\r\n";
  foreach (array_map('trim', explode(',', $to)) as $rcpt) {
    if ($rcpt !== '' && filter_var($rcpt, FILTER_VALIDATE_EMAIL)) {
      try { @mail($rcpt, $subj, implode("\n", $lines), $headers); } catch (\Throwable $e) {}
    }
  }
}

/** Block an IP (manual, admin action). Refuses to block your own current IP. */
function security_block($ip, $reason, $by){
  if (!security_tables()) return [false, 'Security tables unavailable.'];
  $ip = trim((string) $ip);
  if (!filter_var($ip, FILTER_VALIDATE_IP)) return [false, 'That is not a valid IP address.'];
  if ($ip === client_ip()) return [false, 'For safety, you cannot block your own current IP address.'];
  try {
    db()->prepare("INSERT INTO ip_blocklist(ip,reason,status,blocked_by,blocked_at)
                   VALUES(?,?,'active',?,NOW())
                   ON DUPLICATE KEY UPDATE status='active', reason=VALUES(reason),
                     blocked_by=VALUES(blocked_by), blocked_at=NOW(), released_by=NULL, released_at=NULL")
      ->execute([$ip, mb_substr((string)$reason,0,255), mb_substr((string)$by,0,120)]);
    security_log('ip_blocked', 1, "$ip by $by");
    return [true, "Blocked $ip."];
  } catch (\Throwable $e) { return [false, 'Could not block that IP.']; }
}

/** Release (unblock) an IP after verification. */
function security_release($ip, $by){
  if (!security_tables()) return [false, 'Security tables unavailable.'];
  try {
    db()->prepare("UPDATE ip_blocklist SET status='released', released_by=?, released_at=NOW() WHERE ip=?")
      ->execute([mb_substr((string)$by,0,120), trim((string)$ip)]);
    security_log('ip_released', 1, trim((string)$ip) . " by $by");
    return [true, "Released " . trim((string)$ip) . "."];
  } catch (\Throwable $e) { return [false, 'Could not release that IP.']; }
}

/** Recent events for the admin table. */
function security_recent_events($limit = 60){
  if (!security_tables()) return [];
  try {
    $st = db()->query("SELECT * FROM security_events ORDER BY id DESC LIMIT " . (int)$limit);
    return $st->fetchAll();
  } catch (\Throwable $e) { return []; }
}

/** Per-IP rollup (last 7 days) + current block status, for the admin table. */
function security_ip_summary($days = 7){
  if (!security_tables()) return [];
  try {
    $st = db()->prepare(
      "SELECT e.ip,
              COUNT(*) events,
              MAX(e.severity) max_sev,
              MAX(e.created_at) last_seen,
              SUM(e.type='probe') probes,
              SUM(e.type='login_fail') logins,
              b.status block_status
       FROM security_events e
       LEFT JOIN ip_blocklist b ON b.ip = e.ip
       WHERE e.created_at > (NOW() - INTERVAL ? DAY)
       GROUP BY e.ip, b.status
       ORDER BY max_sev DESC, events DESC
       LIMIT 100");
    $st->execute([(int)$days]);
    return $st->fetchAll();
  } catch (\Throwable $e) { return []; }
}

/** Currently-blocked IPs (for the block list panel). */
function security_blocked_ips(){
  if (!security_tables()) return [];
  try { return db()->query("SELECT * FROM ip_blocklist ORDER BY (status='active') DESC, blocked_at DESC LIMIT 100")->fetchAll(); }
  catch (\Throwable $e) { return []; }
}
