<?php
/**
 * Bricks Static — Connector.
 *
 * This file receives static-site deploys from ONE paired WordPress site over
 * signed HTTPS. It writes only inside its own directory, never writes
 * executable files, and refuses any request that isn't signed with the secret
 * it was paired with. Delete it at any time to disconnect.
 *
 * Generated by the Bricks Static plugin — do not edit. To re-pair, download a
 * fresh copy from the plugin's dashboard and replace this file.
 */

declare(strict_types=1);

// ---- Pairing (filled in by the plugin) -------------------------------------
$ID          = '__BS_ID__';
$SECRET_HASH = '__BS_SECRET_HASH__';
$VERSION     = __BS_VERSION__;
$GZIP        = __BS_GZIP__;   // extensions to pre-compress after extract
$GZMIN       = __BS_GZMIN__;  // don't gzip files smaller than this (bytes)

// ---- Tunables ---------------------------------------------------------------
$SKEW_SECONDS   = 300; // accepted clock drift / replay window
$LOCK_FAILURES  = 10;  // failed auths within LOCK_WINDOW → refuse everything
$LOCK_WINDOW    = 600;

// ---- Response plumbing ------------------------------------------------------
header('Content-Type: application/json; charset=UTF-8');
header('Cache-Control: no-store');
header('X-Robots-Tag: noindex, nofollow');

/** @param array<string,mixed> $data */
function bs_out(int $code, array $data): void {
    http_response_code($code);
    echo json_encode($data);
    exit;
}

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    // Nothing to see on GET — no banner, no version.
    bs_out(405, ['ok' => false, 'error' => 'method']);
}

$BASE = realpath(__DIR__);
$SELF = realpath(__FILE__);
if ($BASE === false || $SELF === false) {
    bs_out(500, ['ok' => false, 'error' => 'no base']);
}

// Nonce / lockout state lives in a PHP file that exits when fetched directly,
// so it's never readable over the web even if the host serves dotfiles.
$STATE_FILE = __DIR__ . '/bs-connector-' . $ID . '.state.php';

/** @return array{nonces:array<string,int>,fails:array<int,int>} */
function bs_state_load(string $file): array {
    $state = ['nonces' => [], 'fails' => []];
    if (!is_file($file)) {
        return $state;
    }
    $raw = @file_get_contents($file);
    if ($raw === false) {
        return $state;
    }
    $json = json_decode((string) substr($raw, (int) strpos($raw, "\n") + 1), true);
    if (is_array($json)) {
        $state['nonces'] = is_array($json['nonces'] ?? null) ? $json['nonces'] : [];
        $state['fails']  = is_array($json['fails'] ?? null) ? array_values($json['fails']) : [];
    }
    return $state;
}

/** @param array{nonces:array<string,int>,fails:array<int,int>} $state */
function bs_state_save(string $file, array $state): bool {
    return @file_put_contents($file, "<?php exit; ?>\n" . json_encode($state), LOCK_EX) !== false;
}

// ---- Authentication ---------------------------------------------------------
$hdr = static function (string $name): string {
    $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
    return isset($_SERVER[$key]) ? trim((string) $_SERVER[$key]) : '';
};

$now   = time();
$state = bs_state_load($STATE_FILE);
// Prune old nonces and failures.
$state['nonces'] = array_filter($state['nonces'], static fn(int $t): bool => $t > $now - $SKEW_SECONDS * 2);
$state['fails']  = array_values(array_filter($state['fails'], static fn(int $t): bool => $t > $now - $LOCK_WINDOW));

$fail = static function () use (&$state, $STATE_FILE, $now): void {
    $state['fails'][] = $now;
    bs_state_save($STATE_FILE, $state);
    // One generic answer for every auth failure — probes learn nothing.
    bs_out(403, ['ok' => false, 'error' => 'forbidden']);
};

if (count($state['fails']) >= $LOCK_FAILURES) {
    bs_out(429, ['ok' => false, 'error' => 'locked']);
}

$req_id    = $hdr('X-BS-Id');
$req_ts    = $hdr('X-BS-Timestamp');
$req_nonce = $hdr('X-BS-Nonce');
$req_key   = $hdr('X-BS-Key');
$req_sig   = $hdr('X-BS-Signature');
$req_ver   = (int) $hdr('X-BS-Version');

if ($req_id === '' || !hash_equals($ID, $req_id)) {
    $fail();
}
if (!preg_match('/^\d{9,11}$/', $req_ts) || abs($now - (int) $req_ts) > $SKEW_SECONDS) {
    $fail();
}
if (!preg_match('/^[0-9a-f]{16,64}$/', $req_nonce) || isset($state['nonces'][$req_nonce])) {
    $fail();
}
// Bearer: the file holds only the hash of the secret.
if ($req_key === '' || !hash_equals($SECRET_HASH, hash('sha256', $req_key))) {
    $fail();
}

// Body hash: JSON ops sign the raw body; multipart ops sign meta + file hash.
$is_multipart = isset($_POST['meta']) || isset($_FILES['file']);
if ($is_multipart) {
    $meta_raw  = isset($_POST['meta']) ? (string) $_POST['meta'] : '';
    $file_hash = isset($_FILES['file']['tmp_name']) && is_uploaded_file($_FILES['file']['tmp_name'])
        ? hash_file('sha256', $_FILES['file']['tmp_name'])
        : hash('sha256', '');
    $body_hash = hash('sha256', $meta_raw . "\n" . $file_hash);
    $body      = json_decode($meta_raw, true);
} else {
    $raw       = (string) file_get_contents('php://input');
    $body_hash = hash('sha256', $raw);
    $body      = json_decode($raw, true);
}

$expected = hash_hmac('sha256', implode("\n", [$ID, $req_ts, $req_nonce, $body_hash]), $req_key);
if ($req_sig === '' || !hash_equals($expected, $req_sig)) {
    $fail();
}

// Authenticated. Burn the nonce before doing anything else.
$state['nonces'][$req_nonce] = $now;
if (!bs_state_save($STATE_FILE, $state)) {
    // No replay protection possible → refuse rather than silently degrade.
    bs_out(500, ['ok' => false, 'error' => 'state unwritable']);
}

if ($req_ver !== $VERSION) {
    bs_out(409, ['ok' => false, 'error' => 'version', 'version' => $VERSION]);
}

if (!is_array($body)) {
    bs_out(400, ['ok' => false, 'error' => 'bad body']);
}
$op = isset($body['op']) ? (string) $body['op'] : '';

// ---- Filesystem jail --------------------------------------------------------

/**
 * Normalise a relative path or return null if it tries to escape.
 * (Same rules as the plugin's one-shot deploy helper — keep in sync.)
 */
function bs_safe(string $rel): ?string {
    $rel = str_replace('\\', '/', $rel);
    if ($rel === '' || $rel[0] === '/' || strpos($rel, '../') !== false || strpos($rel, '/..') !== false || $rel === '..' || strpos($rel, ':') !== false || strpos($rel, "\0") !== false) {
        return null;
    }
    return ltrim($rel, '/');
}

/**
 * Whether a file may be WRITTEN at this relative path. Refuses anything a web
 * server could execute (so a stolen secret can deface, never run code), our
 * own connector files, and server-config files that can register handlers.
 */
function bs_write_allowed(string $rel, ?string $content_probe): bool {
    $name = strtolower(basename($rel));
    if (strpos($name, 'bs-connector-') === 0) {
        return false;
    }
    if ($name === 'web.config' || $name === '.user.ini' || $name === 'php.ini') {
        return false;
    }
    static $denied = ['php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar', 'phps', 'pht', 'phpt', 'cgi', 'pl', 'py', 'sh', 'bash', 'ini', 'htpasswd'];
    $parts = explode('.', $name);
    array_shift($parts); // basename before the first dot
    foreach ($parts as $ext) {
        if (in_array($ext, $denied, true)) {
            return false; // catches x.php and x.php.txt alike
        }
    }
    if ($name === '.htaccess' && $content_probe !== null) {
        // Cache/rewrite rules are fine; anything that maps a type to a handler is not.
        if (preg_match('/^\s*(AddHandler|AddType|SetHandler|Action|php_value|php_flag|php_admin_value|php_admin_flag|RemoveHandler|CGIPassAuth)\b/im', $content_probe)) {
            return false;
        }
    }
    return true;
}

/**
 * Absolute destination path for a write, with its parent created — or null
 * when the real (symlink-resolved) location would land outside BASE.
 */
function bs_target(string $base, string $rel): ?string {
    $dest = $base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);
    $dir  = dirname($dest);
    // Deepest existing ancestor must resolve inside BASE (symlink escape guard).
    $probe = $dir;
    while (!is_dir($probe) && strlen($probe) > strlen($base)) {
        $probe = dirname($probe);
    }
    $real = realpath($probe);
    if ($real === false || ($real !== $base && strpos($real, $base . DIRECTORY_SEPARATOR) !== 0)) {
        return null;
    }
    if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
        return null;
    }
    if (is_link($dest) || is_dir($dest)) {
        return null; // never write THROUGH a symlink or over a directory
    }
    return $dest;
}

/** Absolute path of an existing file inside BASE (for read/delete), else null. */
function bs_existing(string $base, string $rel): ?string {
    $path = $base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);
    if (is_link($path) || !is_file($path)) {
        return null;
    }
    $real = realpath($path);
    if ($real === false || strpos($real, $base . DIRECTORY_SEPARATOR) !== 0) {
        return null;
    }
    return $real;
}

/** Write (or drop) the .gz sibling for a compressible file. */
function bs_gzip_sibling(string $dest, array $gzip_ext, int $gzmin): void {
    $ext = strtolower(pathinfo($dest, PATHINFO_EXTENSION));
    if (!in_array($ext, $gzip_ext, true) || !function_exists('gzencode')) {
        return;
    }
    $data = @file_get_contents($dest);
    if ($data !== false && strlen($data) >= $gzmin) {
        $gz = @gzencode($data, 9);
        if ($gz !== false && strlen($gz) < strlen($data)) {
            @file_put_contents($dest . '.gz', $gz);
            return;
        }
    }
    if (is_file($dest . '.gz')) {
        @unlink($dest . '.gz');
    }
}

/** Parse a php.ini shorthand size ("8M") to bytes. */
function bs_ini_bytes(string $value): int {
    $value = trim($value);
    if ($value === '' || $value === '-1') {
        return 0;
    }
    $unit = strtolower(substr($value, -1));
    $num  = (int) $value;
    switch ($unit) {
        case 'g':
            return $num * 1024 * 1024 * 1024;
        case 'm':
            return $num * 1024 * 1024;
        case 'k':
            return $num * 1024;
    }
    return $num;
}

$part_path = static function (string $upload) use ($BASE, $ID): ?string {
    if (!preg_match('/^[0-9a-f]{16}$/', $upload)) {
        return null;
    }
    return $BASE . DIRECTORY_SEPARATOR . 'bs-connector-' . $ID . '.' . $upload . '.part';
};

// ---- Operations -------------------------------------------------------------
switch ($op) {

    case 'ping': {
        $limits = array_filter([bs_ini_bytes((string) ini_get('upload_max_filesize')), bs_ini_bytes((string) ini_get('post_max_size'))]);
        bs_out(200, [
            'ok'        => true,
            'version'   => $VERSION,
            'php'       => PHP_VERSION,
            'zip'       => class_exists('ZipArchive'),
            'gzip'      => function_exists('gzencode'),
            'writable'  => is_writable($BASE),
            'free'      => (int) @disk_free_space($BASE),
            'uploadMax' => $limits === [] ? 0 : min($limits),
            'server'    => isset($_SERVER['SERVER_SOFTWARE']) ? (string) $_SERVER['SERVER_SOFTWARE'] : '',
            'dir'       => basename($BASE),
        ]);
    }

    case 'manifest': {
        // Inventory of the folder (size + md5 per file) so the plugin can
        // rebuild its push record after a wipe or a fresh pairing. Skips our
        // own files, in-flight parts, server config and .gz siblings (which
        // the plugin regenerates). Read-only; never lists outside BASE.
        $files = [];
        $it    = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($BASE, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY);
        foreach ($it as $f) {
            if (!$f->isFile() || $f->isLink()) {
                continue;
            }
            $name = $f->getFilename();
            if (strpos($name, 'bs-connector-') === 0 || substr($name, -3) === '.gz' || $name === '.htaccess' || $name === '.htaccess.bricks-static.bak') {
                continue;
            }
            $rel = ltrim(str_replace('\\', '/', substr($f->getPathname(), strlen($BASE))), '/');
            $files[$rel] = ['size' => (int) $f->getSize(), 'md5' => (string) md5_file($f->getPathname())];
        }
        ksort($files);
        bs_out(200, ['ok' => true, 'files' => $files]);
    }

    case 'stat': {
        $out   = [];
        $paths = is_array($body['paths'] ?? null) ? $body['paths'] : [];
        foreach ($paths as $p) {
            $rel = bs_safe((string) $p);
            if ($rel !== null) {
                $out[$rel] = bs_existing($BASE, $rel) !== null;
            }
        }
        bs_out(200, ['ok' => true, 'exists' => $out]);
    }

    case 'get': {
        // Read is allow-listed: only the server-config files the plugin merges.
        $rel = bs_safe((string) ($body['path'] ?? ''));
        if ($rel === null || !in_array($rel, ['.htaccess', '.htaccess.bricks-static.bak'], true)) {
            bs_out(400, ['ok' => false, 'error' => 'not readable']);
        }
        $path = bs_existing($BASE, $rel);
        if ($path === null) {
            bs_out(200, ['ok' => true, 'exists' => false, 'content' => '']);
        }
        bs_out(200, ['ok' => true, 'exists' => true, 'content' => base64_encode((string) file_get_contents($path))]);
    }

    case 'chunk': {
        // Append one chunk of an upload. `offset` makes retries idempotent; the
        // final chunk carries the whole-file sha256 and either a `target`
        // (relative path → move into place) or nothing (keep as a package zip).
        if (!$is_multipart || !isset($_FILES['file']['tmp_name']) || !is_uploaded_file($_FILES['file']['tmp_name'])) {
            bs_out(400, ['ok' => false, 'error' => 'no file']);
        }
        $part = $part_path((string) ($body['upload'] ?? ''));
        if ($part === null) {
            bs_out(400, ['ok' => false, 'error' => 'bad upload id']);
        }
        $offset = (int) ($body['offset'] ?? -1);
        $size   = (int) $_FILES['file']['size'];
        clearstatcache(true, $part);
        $have   = is_file($part) ? (int) filesize($part) : 0;
        if ($offset < 0 || ($offset === 0 && $have > 0 && $have !== $size)) {
            // A fresh upload id but leftovers on disk from an aborted run.
            @unlink($part);
            $have = 0;
        }
        if ($have === $offset + $size) {
            // Already received (retry) — fall through to finalise if asked.
        } elseif ($have !== $offset) {
            bs_out(409, ['ok' => false, 'error' => 'offset', 'have' => $have]);
        } else {
            $in  = fopen($_FILES['file']['tmp_name'], 'rb');
            $out = fopen($part, 'ab');
            if ($in === false || $out === false) {
                bs_out(500, ['ok' => false, 'error' => 'cannot write']);
            }
            stream_copy_to_stream($in, $out);
            fclose($in);
            fclose($out);
        }

        clearstatcache(true, $part);
        $result = ['ok' => true, 'received' => (int) filesize($part)];
        if (!empty($body['final'])) {
            $sha = (string) ($body['sha256'] ?? '');
            if (!preg_match('/^[0-9a-f]{64}$/', $sha) || !hash_equals($sha, (string) hash_file('sha256', $part))) {
                @unlink($part);
                bs_out(422, ['ok' => false, 'error' => 'checksum']);
            }
            $target = null;
            if (isset($body['target'])) {
                $target = bs_safe((string) $body['target']);
                if ($target === null) {
                    @unlink($part);
                    bs_out(400, ['ok' => false, 'error' => 'unsafe target']);
                }
            }
            if ($target !== null) {
                $probe = $target === '.htaccess' ? (string) file_get_contents($part) : null;
                if (!bs_write_allowed($target, $probe)) {
                    @unlink($part);
                    bs_out(400, ['ok' => false, 'error' => 'not allowed: ' . $target]);
                }
                $dest = bs_target($BASE, $target);
                if ($dest === null || !@rename($part, $dest)) {
                    @unlink($part);
                    bs_out(500, ['ok' => false, 'error' => 'cannot place: ' . $target]);
                }
                $result['placed'] = $target;
            } else {
                if (!@rename($part, substr($part, 0, -5) . '.zip')) {
                    @unlink($part);
                    bs_out(500, ['ok' => false, 'error' => 'cannot finalise package']);
                }
                $result['package'] = true;
            }
        }
        bs_out(200, $result);
    }

    case 'extract': {
        $part = $part_path((string) ($body['upload'] ?? ''));
        if ($part === null) {
            bs_out(400, ['ok' => false, 'error' => 'bad upload id']);
        }
        $zip_path = substr($part, 0, -5) . '.zip';
        if (!is_file($zip_path)) {
            bs_out(404, ['ok' => false, 'error' => 'no package']);
        }
        if (!class_exists('ZipArchive')) {
            @unlink($zip_path);
            bs_out(500, ['ok' => false, 'error' => 'ZipArchive unavailable']);
        }
        $zip = new ZipArchive();
        if ($zip->open($zip_path) !== true) {
            @unlink($zip_path);
            bs_out(500, ['ok' => false, 'error' => 'cannot open package']);
        }

        $out = ['ok' => true, 'extracted' => 0, 'deleted' => 0, 'errors' => []];
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = $zip->getNameIndex($i);
            if ($name === false || substr($name, -1) === '/') {
                continue;
            }
            $rel = bs_safe($name);
            if ($rel === null) {
                $out['errors'][] = 'unsafe:' . $name;
                continue;
            }
            $probe = basename($rel) === '.htaccess' ? (string) $zip->getFromIndex($i) : null;
            if (!bs_write_allowed($rel, $probe)) {
                $out['errors'][] = 'denied:' . $rel;
                continue;
            }
            $dest = bs_target($BASE, $rel);
            if ($dest === null) {
                $out['errors'][] = 'outside:' . $rel;
                continue;
            }
            $src = $zip->getStream($name);
            if ($src === false) {
                $out['errors'][] = 'read:' . $rel;
                continue;
            }
            $dst = @fopen($dest, 'wb');
            if ($dst === false) {
                fclose($src);
                $out['errors'][] = 'write:' . $rel;
                continue;
            }
            stream_copy_to_stream($src, $dst);
            fclose($dst);
            fclose($src);
            $out['extracted']++;
            bs_gzip_sibling($dest, $GZIP, $GZMIN);
        }
        $zip->close();
        @unlink($zip_path);

        $deletes = is_array($body['deletes'] ?? null) ? $body['deletes'] : [];
        foreach ($deletes as $del) {
            $rel = bs_safe((string) $del);
            if ($rel === null || strpos(basename($rel), 'bs-connector-') === 0) {
                continue;
            }
            $path = bs_existing($BASE, $rel);
            if ($path !== null && @unlink($path)) {
                $out['deleted']++;
            }
            $gz = bs_existing($BASE, $rel . '.gz');
            if ($gz !== null) {
                @unlink($gz);
            }
        }
        bs_out(200, $out);
    }

    case 'delete': {
        $paths   = is_array($body['paths'] ?? null) ? $body['paths'] : [];
        $deleted = 0;
        foreach ($paths as $p) {
            $rel = bs_safe((string) $p);
            if ($rel === null || strpos(basename($rel), 'bs-connector-') === 0) {
                continue;
            }
            $path = bs_existing($BASE, $rel);
            if ($path !== null && @unlink($path)) {
                $deleted++;
                // Prune now-empty parents, but never BASE itself.
                $dir = dirname($path);
                while ($dir !== $BASE && strpos($dir, $BASE . DIRECTORY_SEPARATOR) === 0 && @rmdir($dir)) {
                    $dir = dirname($dir);
                }
            }
        }
        bs_out(200, ['ok' => true, 'deleted' => $deleted]);
    }

    default:
        bs_out(400, ['ok' => false, 'error' => 'unknown op']);
}
