<?php
/**
 * Disk-cleanup detection + execution. Root-only.
 *
 *   GET  ?action=detect   -> JSON { success, candidates: [{path, size, reason}] }
 *   POST ?action=execute  -> body: { paths: [...] } -> deletes whitelisted paths
 *
 * The user-flow: banner button (or AI Monitor page) opens a modal that
 * calls detect; user reviews and selects items; execute deletes them.
 *
 * Safety rules:
 *  - Whitelisted prefixes only: /var/log, /var/lib/docker/containers,
 *    /docker/users/<u>/<dom>/logs, journalctl rotated archives.
 *  - Whitelisted name patterns only: *.gz, *.[0-9]*, *-json.log (Docker),
 *    or files matching the journal pattern.
 *  - File age > 30 days for rotated archives, >= now for active Docker
 *    json logs (we truncate via `: > path`, not `unlink`).
 *  - Each path is re-validated server-side at execute time -- the JSON
 *    payload from the client is treated as untrusted; we don't trust the
 *    client's "this passed detect" assertion.
 *  - Path traversal blocks: realpath() must stay inside one of the
 *    whitelisted roots after canonicalization.
 */

header('Content-Type: application/json');

if (!defined('AUTH_USER') || AUTH_USER !== 'root') {
    http_response_code(403);
    echo json_encode(['success' => false, 'error' => 'root only']);
    exit;
}

$action = $_REQUEST['action'] ?? '';

if ($action === 'detect') {
    echo json_encode([
        'success'    => true,
        'candidates' => detect_cleanup_candidates(),
    ]);
    exit;
}

if ($action === 'execute') {
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
        http_response_code(405);
        echo json_encode(['success' => false, 'error' => 'method not allowed']);
        exit;
    }
    $raw = file_get_contents('php://input');
    $body = json_decode($raw, true);
    $paths = $body['paths'] ?? [];
    if (!is_array($paths) || empty($paths)) {
        echo json_encode(['success' => false, 'error' => 'no paths']);
        exit;
    }
    $result = execute_cleanup($paths);
    echo json_encode($result);
    exit;
}

http_response_code(400);
echo json_encode(['success' => false, 'error' => 'unknown action']);
exit;

// ----------------------------------------------------------------------

/**
 * Whitelisted root directories. A candidate path's realpath must be a
 * descendant of one of these for it to be considered safe to delete.
 */
function cleanup_roots(): array {
    return ['/var/log', '/var/lib/docker/containers', '/docker/users'];
}

/**
 * Decide whether a given path is safe to remove. Combines the root-prefix
 * check with the name-pattern + age + size rules.
 */
function is_cleanable(string $path): bool {
    $real = realpath($path);
    if ($real === false || !is_file($real)) return false;
    $under_whitelisted_root = false;
    foreach (cleanup_roots() as $root) {
        if (strncmp($real, $root . '/', strlen($root) + 1) === 0) {
            $under_whitelisted_root = true;
            break;
        }
    }
    if (!$under_whitelisted_root) return false;
    $base = basename($real);
    $size = @filesize($real) ?: 0;
    $mtime = @filemtime($real) ?: 0;
    $now = time();
    $age_days = ($now - $mtime) / 86400;

    // Docker JSON logs -- truncate (not unlink) when bigger than 100MB.
    if (preg_match('/-json\.log(\.\d+)?$/', $base) && $size > 100 * 1024 * 1024) return true;
    // Rotated journal archives.
    if (preg_match('/^system@.+\.journal$/', $base) && $age_days > 30) return true;
    // Generic rotated logs (.log.1, .log.1.gz, error_log.1.gz).
    if (preg_match('/\.(log|err|out)?\.\d+(\.gz)?$/', $base) && $age_days > 30 && $size > 1024 * 1024) return true;
    // Plain *.gz under whitelisted roots, age + size guarded.
    if (substr($base, -3) === '.gz' && $age_days > 30 && $size > 1024 * 1024) return true;
    return false;
}

/**
 * Walk the whitelisted roots and collect candidates that match
 * is_cleanable(). Capped at 500 entries to keep the response bounded.
 */
function detect_cleanup_candidates(): array {
    $candidates = [];
    foreach (cleanup_roots() as $root) {
        if (!is_dir($root)) continue;
        $iter = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::LEAVES_ONLY,
            RecursiveIteratorIterator::CATCH_GET_CHILD // ignore unreadable subdirs
        );
        foreach ($iter as $f) {
            if (count($candidates) >= 500) break 2;
            $path = $f->getPathname();
            if (!is_cleanable($path)) continue;
            $base = basename($path);
            $reason = explain_reason($base, (int)$f->getSize(), (int)$f->getMTime());
            $candidates[] = [
                'path'   => $path,
                'size'   => (int)$f->getSize(),
                'reason' => $reason,
            ];
        }
    }
    usort($candidates, function($a, $b) { return $b['size'] - $a['size']; });
    return $candidates;
}

function explain_reason(string $base, int $size, int $mtime): string {
    $age_days = floor((time() - $mtime) / 86400);
    if (preg_match('/-json\.log(\.\d+)?$/', $base)) return 'Docker container JSON log; will be truncated in place';
    if (preg_match('/\.(log|err|out)?\.\d+(\.gz)?$/', $base) || substr($base, -3) === '.gz') {
        return "Rotated log, age {$age_days}d";
    }
    if (preg_match('/^system@.+\.journal$/', $base)) return "Archived journal, age {$age_days}d";
    return 'Rotated log';
}

/**
 * Execute deletion (or truncation, for active Docker JSON logs) for the
 * given paths. Each path is re-validated through is_cleanable() so even
 * if the client crafts paths that didn't come from detect, they still
 * have to pass the same safety bar.
 */
function execute_cleanup(array $paths): array {
    $deleted = 0;
    $freed = 0;
    $errors = [];
    foreach ($paths as $path) {
        if (!is_string($path) || $path === '') continue;
        if (!is_cleanable($path)) {
            $errors[] = "rejected: {$path}";
            continue;
        }
        $real = realpath($path);
        $size = @filesize($real) ?: 0;
        $base = basename($real);

        // Docker container json logs: truncate, don't unlink (the daemon
        // keeps the file open with a long-lived FD).
        if (preg_match('/-json\.log(\.\d+)?$/', $base)) {
            $fp = @fopen($real, 'r+');
            if ($fp) {
                @ftruncate($fp, 0);
                fclose($fp);
                $deleted++;
                $freed += $size;
            } else {
                $errors[] = "truncate failed: {$real}";
            }
            continue;
        }
        // Everything else: regular unlink.
        if (@unlink($real)) {
            $deleted++;
            $freed += $size;
        } else {
            $errors[] = "unlink failed: {$real}";
        }
    }
    return [
        'success'  => true,
        'deleted'  => $deleted,
        'freed_mb' => round($freed / 1024 / 1024, 1),
        'errors'   => $errors,
    ];
}
