<?php
/**
 * Coraza Rules — admin page for runtime rule tuning.
 * Root-only. Firing-rules tab driven by coraza_rule_hits (populated by the
 * 5-min audit-log rollup cron).
 */

if (!defined('AUTH_USER') || AUTH_USER !== 'root') {
    header('Location: index.php?page=dashboard');
    exit;
}

require_once('/docker/whp/web/libs/coraza_rule_manager.php');

$m = new coraza_rule_manager();
$overrides = [];
foreach ($m->list_all_overrides() as $row) {
    $overrides[(int)$row['rule_id']] = $row;
}
$host_exceptions = $m->list_all_host_exceptions();
$window = ($_GET['window'] ?? '24h') === '7d' ? '7d' : '24h';

$db = new SQLite3('/docker/whp/sql/security.db');
$stmt = $db->prepare('SELECT rule_id, hits, top_hosts, top_uris FROM coraza_rule_hits
                      WHERE window = :w ORDER BY hits DESC LIMIT 200');
$stmt->bindValue(':w', $window, SQLITE3_TEXT);
$res = $stmt->execute();
$rows = [];
while ($r = $res->fetchArray(SQLITE3_ASSOC)) { $rows[] = $r; }
?>
<div class="d-flex justify-content-between align-items-center mb-3">
    <h1 class="h3">Coraza Rules</h1>
    <div class="btn-group" role="group">
        <a href="?page=coraza-rules&window=24h"
           class="btn btn-sm btn-<?= $window === '24h' ? 'primary' : 'outline-primary' ?>">Last 24h</a>
        <a href="?page=coraza-rules&window=7d"
           class="btn btn-sm btn-<?= $window === '7d' ? 'primary' : 'outline-primary' ?>">Last 7d</a>
    </div>
</div>

<ul class="nav nav-tabs mb-3" role="tablist">
    <li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-firing" type="button">Firing rules</button></li>
    <li class="nav-item"><button class="nav-link"        data-bs-toggle="tab" data-bs-target="#tab-catalog" type="button">CRS catalog</button></li>
    <li class="nav-item"><button class="nav-link"        data-bs-toggle="tab" data-bs-target="#tab-activity" type="button">Activity</button></li>
</ul>
<div class="tab-content">
    <div class="tab-pane fade show active" id="tab-firing">

        <p class="text-muted small">
            Tunes the Coraza WAF without rebuilding the sidecar image.
            Source of truth: <code>security.db</code>. Reload via SIGHUP after every save (~10ms, no traffic blip).
        </p>

        <?php if (empty($rows)): ?>
            <div class="alert alert-info">No rule activity yet. Rollup runs every 5 minutes from <code>/var/log/coraza/audit.log</code>.</div>
        <?php else: ?>
        <table class="table table-sm table-hover align-middle">
            <thead>
                <tr>
                    <th>Rule ID</th>
                    <th class="text-end">Hits</th>
                    <th>Top hosts</th>
                    <th>Top URIs</th>
                    <th>State</th>
                    <th>Per-host</th>
                </tr>
            </thead>
            <tbody>
            <?php foreach ($rows as $r): $rid = (int)$r['rule_id']; ?>
                <?php
                    $state = $overrides[$rid]['state'] ?? 'default';
                    $hosts = json_decode($r['top_hosts'], true) ?: [];
                    $uris  = json_decode($r['top_uris'], true)  ?: [];
                ?>
                <tr data-rule-id="<?= $rid ?>">
                    <td><code><?= $rid ?></code> <a href="#" class="coraza-view-matches small ms-2" data-rule-id="<?= $rid ?>">view</a></td>
                    <td class="text-end"><?= (int)$r['hits'] ?></td>
                    <td class="small">
                        <?php foreach (array_slice($hosts, 0, 3) as $h): ?>
                            <?= htmlspecialchars($h['host']) ?> <span class="text-muted">(<?= (int)$h['n'] ?>)</span><br>
                        <?php endforeach; ?>
                    </td>
                    <td class="small text-truncate" style="max-width:280px;">
                        <?php foreach (array_slice($uris, 0, 3) as $u): ?>
                            <code><?= htmlspecialchars($u['uri']) ?></code><br>
                        <?php endforeach; ?>
                    </td>
                    <td>
                        <select class="form-select form-select-sm coraza-state-select" data-rule-id="<?= $rid ?>">
                            <option value="default" <?= $state === 'default' ? 'selected' : '' ?>>(default)</option>
                            <option value="enforce" <?= $state === 'enforce' ? 'selected' : '' ?>>enforce</option>
                            <option value="detect"  <?= $state === 'detect'  ? 'selected' : '' ?>>detect</option>
                            <option value="off"     <?= $state === 'off'     ? 'selected' : '' ?>>off</option>
                        </select>
                    </td>
                    <td>
                        <?php $n = count($host_exceptions[$rid] ?? []); ?>
                        <button class="btn btn-sm btn-outline-secondary coraza-host-edit" data-rule-id="<?= $rid ?>">
                            Hosts (<?= $n ?>)
                        </button>
                    </td>
                </tr>
            <?php endforeach; ?>
            </tbody>
        </table>
        <?php endif; ?>

    </div>

    <div class="tab-pane fade" id="tab-catalog">
        <?php
            $catalog = file_exists('/docker/coraza/rules-catalog.json')
                ? json_decode(file_get_contents('/docker/coraza/rules-catalog.json'), true)
                : [];
        ?>
        <p class="text-muted small">All <?= count($catalog) ?> rules in the bundled CRS catalog. Search by ID, message, or tag.</p>
        <input type="text" class="form-control mb-3" id="catSearch" placeholder="Search by ID, message, tag...">
        <table class="table table-sm">
            <thead><tr><th>Rule ID</th><th>Severity</th><th>Message</th><th>Tags</th><th>State</th></tr></thead>
            <tbody id="catBody">
            <?php foreach ($catalog as $rule): $rid = (int)$rule['id']; ?>
                <?php $state = $overrides[$rid]['state'] ?? 'default'; ?>
                <tr data-rule-id="<?= $rid ?>" data-search="<?= htmlspecialchars(strtolower("$rid {$rule['msg']} " . implode(' ', $rule['tags'] ?? []))) ?>">
                    <td><code><?= $rid ?></code></td>
                    <td><span class="badge bg-secondary"><?= htmlspecialchars($rule['severity'] ?? '') ?></span></td>
                    <td class="small"><?= htmlspecialchars($rule['msg'] ?? '') ?></td>
                    <td class="small text-muted"><?= htmlspecialchars(implode(', ', $rule['tags'] ?? [])) ?></td>
                    <td>
                        <select class="form-select form-select-sm coraza-state-select" data-rule-id="<?= $rid ?>">
                            <option value="default" <?= $state === 'default' ? 'selected' : '' ?>>(default)</option>
                            <option value="enforce" <?= $state === 'enforce' ? 'selected' : '' ?>>enforce</option>
                            <option value="detect"  <?= $state === 'detect'  ? 'selected' : '' ?>>detect</option>
                            <option value="off"     <?= $state === 'off'     ? 'selected' : '' ?>>off</option>
                        </select>
                    </td>
                </tr>
            <?php endforeach; ?>
            </tbody>
        </table>
    </div>

    <div class="tab-pane fade" id="tab-activity">
        <p class="text-muted small">Most recent 100 changes.</p>
        <table class="table table-sm">
            <thead><tr><th>When</th><th>Actor</th><th>Rule</th><th>Action</th><th>Details</th></tr></thead>
            <tbody>
            <?php foreach ($m->recent_audit(100) as $a): ?>
                <tr>
                    <td class="small text-nowrap"><?= htmlspecialchars(date('Y-m-d H:i:s', (int)$a['ts'])) ?></td>
                    <td><?= htmlspecialchars((string)$a['actor']) ?></td>
                    <td><code><?= (int)$a['rule_id'] ?></code></td>
                    <td><?= htmlspecialchars((string)$a['action']) ?></td>
                    <td class="small text-muted"><?= htmlspecialchars(json_encode($a['payload'])) ?></td>
                </tr>
            <?php endforeach; ?>
            </tbody>
        </table>
    </div>
</div>

<input type="hidden" id="csrf-token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">

<script>
document.querySelectorAll('.coraza-state-select').forEach(sel => {
    sel.addEventListener('change', async ev => {
        const ruleId = ev.target.dataset.ruleId;
        const state = ev.target.value;
        const params = new URLSearchParams({
            rule_id: ruleId,
            csrf_token: document.getElementById('csrf-token').value,
        });
        if (state === 'default') {
            params.set('action', 'clear_state');
        } else {
            params.set('action', 'set_state');
            params.set('state', state);
        }
        const r = await fetch('api/coraza-rules-save.php', { method: 'POST', body: params });
        const j = await r.json();
        if (j.ok) {
            ev.target.classList.add('is-valid');
            setTimeout(() => ev.target.classList.remove('is-valid'), 1500);
        } else {
            alert('Save failed: ' + j.error);
            window.location.reload();
        }
    });
});

document.getElementById('catSearch')?.addEventListener('input', ev => {
    const q = ev.target.value.toLowerCase();
    document.querySelectorAll('#catBody tr').forEach(tr => {
        tr.style.display = (q === '' || tr.dataset.search.includes(q)) ? '' : 'none';
    });
});
</script>

<div class="modal fade" id="hostExceptionModal" tabindex="-1">
    <div class="modal-dialog modal-lg">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">Host exceptions for rule <code id="hxRuleId"></code></h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
            </div>
            <div class="modal-body">
                <p class="small text-muted">
                    Hostnames listed here are exempt from the rule. Useful for
                    keeping a rule enforced server-wide while letting it pass
                    for specific sites (e.g. Mastodon federation on HPR).
                </p>
                <div class="input-group mb-3">
                    <input type="text" class="form-control" id="hxHostInput" placeholder="hostname" autocomplete="off">
                    <button class="btn btn-primary" id="hxAddBtn">Add</button>
                </div>
                <ul class="list-group" id="hxHostList"></ul>
            </div>
        </div>
    </div>
</div>

<script>
const hxModal = new bootstrap.Modal('#hostExceptionModal');
let hxCurrentRule = null;

async function hxLoadHosts(ruleId) {
    const r = await fetch('api/coraza-rules-host-list.php?rule_id=' + encodeURIComponent(ruleId));
    const list = await r.json();
    const ul = document.getElementById('hxHostList');
    ul.innerHTML = '';
    for (const h of list) {
        const li = document.createElement('li');
        li.className = 'list-group-item d-flex justify-content-between align-items-center';
        const code = document.createElement('code');
        code.textContent = h.hostname;
        const btn = document.createElement('button');
        btn.className = 'btn btn-sm btn-outline-danger hx-remove';
        btn.dataset.host = h.hostname;
        btn.textContent = 'Remove';
        li.appendChild(code);
        li.appendChild(btn);
        ul.appendChild(li);
    }
    ul.querySelectorAll('.hx-remove').forEach(b => {
        b.addEventListener('click', () => hxRemove(b.dataset.host));
    });
}

async function hxAdd(host) {
    const body = new URLSearchParams({
        action: 'add_host', rule_id: hxCurrentRule, hostname: host,
        csrf_token: document.getElementById('csrf-token').value,
    });
    const r = await fetch('api/coraza-rules-save.php', { method: 'POST', body });
    const j = await r.json();
    if (!j.ok) { alert(j.error); return; }
    await hxLoadHosts(hxCurrentRule);
    document.getElementById('hxHostInput').value = '';
}

async function hxRemove(host) {
    const body = new URLSearchParams({
        action: 'remove_host', rule_id: hxCurrentRule, hostname: host,
        csrf_token: document.getElementById('csrf-token').value,
    });
    const r = await fetch('api/coraza-rules-save.php', { method: 'POST', body });
    const j = await r.json();
    if (!j.ok) { alert(j.error); return; }
    await hxLoadHosts(hxCurrentRule);
}

document.querySelectorAll('.coraza-host-edit').forEach(btn => {
    btn.addEventListener('click', async () => {
        hxCurrentRule = btn.dataset.ruleId;
        document.getElementById('hxRuleId').textContent = hxCurrentRule;
        document.getElementById('hxHostInput').value = '';
        await hxLoadHosts(hxCurrentRule);
        hxModal.show();
    });
});
document.getElementById('hxAddBtn').addEventListener('click', () => {
    const v = document.getElementById('hxHostInput').value.trim();
    if (v) hxAdd(v);
});
document.getElementById('hxHostInput').addEventListener('keydown', ev => {
    if (ev.key === 'Enter') {
        ev.preventDefault();
        const v = ev.target.value.trim();
        if (v) hxAdd(v);
    }
});

const matchesPanel = new bootstrap.Offcanvas('#matchesPanel');
document.querySelectorAll('.coraza-view-matches').forEach(a => {
    a.addEventListener('click', async ev => {
        ev.preventDefault();
        const rid = a.dataset.ruleId;
        document.getElementById('mpRuleId').textContent = rid;
        document.getElementById('mpBody').textContent = 'Loading...';
        matchesPanel.show();
        const r = await fetch('api/coraza-rules-recent-matches.php?rule_id=' + encodeURIComponent(rid));
        const list = await r.json();
        const body = document.getElementById('mpBody');
        if (list.length === 0) {
            body.textContent = 'No recent matches.';
            return;
        }
        body.innerHTML = '';
        for (const m of list) {
            const div = document.createElement('div');
            div.className = 'border-bottom py-2';
            const meta = document.createElement('div');
            meta.className = 'text-muted';
            meta.textContent = `${m.ts} — ${m.client} → ${m.host}`;
            const req = document.createElement('div');
            const code = document.createElement('code');
            code.textContent = `${m.method} ${m.uri}`;
            req.appendChild(code);
            div.appendChild(meta);
            div.appendChild(req);
            body.appendChild(div);
        }
    });
});
</script>

<div class="offcanvas offcanvas-end" tabindex="-1" id="matchesPanel" style="width: 600px;">
    <div class="offcanvas-header">
        <h5 class="offcanvas-title">Recent matches: <code id="mpRuleId"></code></h5>
        <button class="btn-close" data-bs-dismiss="offcanvas"></button>
    </div>
    <div class="offcanvas-body small" id="mpBody">Loading...</div>
</div>
