<?php
function followRedirects($url, $ip, $maxRedirects = 5, $timeout = 10) {

    $parsed = parse_url($url);
    if (!$parsed || !isset($parsed['host'])) {
        return ['error' => 'Invalid URL'];
    }

    $host = $parsed['host'];
    $wwwHost = (strpos($host, 'www.') === 0) ? $host : 'www.' . $host;
    $bareHost = (strpos($host, 'www.') === 0) ? substr($host, 4) : $host;

    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
    curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirects);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout);
    curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
    curl_setopt($ch, CURLOPT_RESOLVE, [
        "{$bareHost}:80:{$ip}",
        "{$bareHost}:443:{$ip}",
        "{$wwwHost}:80:{$ip}",
        "{$wwwHost}:443:{$ip}",
    ]);
    $result = curl_exec($ch);

    if (curl_errno($ch)) {
        $error_message = curl_error($ch);
        curl_close($ch);
        return ['error' => $error_message];
    }

    $effectiveUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
    curl_close($ch);

    return ['url' => $effectiveUrl ?: $url];
}

function validateIpAddress($ip) {
    // Reject private (RFC1918), loopback, link-local, and other reserved
    // ranges so this endpoint cannot be used as an SSRF primitive.
    return filter_var(
        $ip,
        FILTER_VALIDATE_IP,
        FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
    );
}

function validateDomain($domain) {
    if (!is_string($domain) || strlen($domain) > 253) {
        return false;
    }
    return (bool) preg_match(
        '/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/',
        $domain
    );
}

function checkRateLimit($clientIp, $limit = 10, $windowSeconds = 60) {
    $dir = sys_get_temp_dir() . '/nfpreview_rl';
    if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) {
        return true; // fail-open if cache dir can't be created
    }
    $file = $dir . '/' . hash('sha256', (string) $clientIp);
    $now = time();
    $fh = @fopen($file, 'c+');
    if (!$fh) {
        return true;
    }
    flock($fh, LOCK_EX);
    $raw = stream_get_contents($fh);
    $timestamps = [];
    if ($raw !== false && $raw !== '') {
        foreach (explode("\n", $raw) as $line) {
            $line = trim($line);
            if ($line !== '' && ctype_digit($line) && ($now - (int) $line) < $windowSeconds) {
                $timestamps[] = (int) $line;
            }
        }
    }
    $allowed = count($timestamps) < $limit;
    if ($allowed) {
        $timestamps[] = $now;
    }
    ftruncate($fh, 0);
    rewind($fh);
    fwrite($fh, implode("\n", $timestamps));
    flock($fh, LOCK_UN);
    fclose($fh);
    return $allowed;
}

function testAndReloadConfig($configPath) {
    // Make sure we start with clean arrays
    $output = [];
    $testStatus = 0;

    // Capture stderr too
    exec('sudo /usr/sbin/angie -t 2>&1', $output, $testStatus);

    // Log the output for debugging
    error_log("NFPreview: angie -t exit code = {$testStatus}");
    error_log("NFPreview: angie -t output:\n" . implode("\n", $output));

    if ($testStatus !== 0) {
        // Only unlink if the file really exists
        if (is_file($configPath)) {
            unlink($configPath);
        }

        // Optional: try to reload old config, but log result
        $reloadOutput = [];
        $reloadStatus = 0;
        exec('sudo /usr/sbin/service angie reload 2>&1', $reloadOutput, $reloadStatus);
        error_log("NFPreview: angie reload after failed test, exit = {$reloadStatus}");
        error_log("NFPreview: reload output:\n" . implode("\n", $reloadOutput));

        return false;
    }

    // Test OK → reload
    $reloadOutput = [];
    $reloadStatus = 0;
    exec('sudo /usr/sbin/service angie reload 2>&1', $reloadOutput, $reloadStatus);

    error_log("NFPreview: angie reload exit code = {$reloadStatus}");
    error_log("NFPreview: angie reload output:\n" . implode("\n", $reloadOutput));

    return $reloadStatus === 0;
}


session_start();
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrfToken = $_SESSION['csrf_token'];

$response = '';
$isPost = ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST';

if ($isPost) {
    $submittedToken = $_POST['csrf'] ?? '';
    if (!is_string($submittedToken) || !hash_equals($csrfToken, $submittedToken)) {
        http_response_code(403);
        echo 'Invalid CSRF token.';
        exit;
    }

    $clientIp = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
    if (!checkRateLimit($clientIp)) {
        http_response_code(429);
        echo 'Rate limit exceeded. Please wait a minute and try again.';
        exit;
    }

    $domain = is_string($_POST['domain'] ?? null) ? trim($_POST['domain']) : '';
    $ip     = is_string($_POST['ip'] ?? null)     ? trim($_POST['ip'])     : '';

    if ($domain === '' || $ip === '') {
        $response = 'Please provide both domain and IP address.';
    } elseif (!validateIpAddress($ip) || !validateDomain($domain)) {
        $response = 'Invalid domain or IP. Please provide a valid public domain and IP address.';
    } else {
        $initialUrl = (str_starts_with($domain, 'http://') || str_starts_with($domain, 'https://'))
            ? $domain
            : 'http://' . $domain;

        $checkedDomainResponse = followRedirects($initialUrl, $ip);

        if (isset($checkedDomainResponse['error'])) {
            error_log('NFPreview: curl error: ' . $checkedDomainResponse['error']);
            $response = 'Unable to reach the target server. Please verify the domain and IP.';
        } else {
            $parsedUrl   = parse_url($checkedDomainResponse['url']);
            $finalDomain = $parsedUrl['host'] ?? $domain;

            // Re-validate the post-redirect host with the same strict rules —
            // it is attacker-controlled via the origin's Location header.
            if (!validateDomain($finalDomain)) {
                $finalDomain = $domain;
            }
            $finalDomain = strtolower($finalDomain);

            // Build the preview subdomain and strictly sanitize to the
            // RFC 1035 label charset before touching the filesystem or
            // nginx config.
            $suffix     = bin2hex(random_bytes(4));
            $labelStub  = str_replace('.', '', $finalDomain);
            $labelStub  = preg_replace('/[^a-z0-9\-]/', '', $labelStub);
            if ($labelStub === '' || $labelStub === null) {
                $labelStub = 'site';
            }
            $tempUrl    = substr($labelStub, 0, 40) . '-' . $suffix . '.nfpreview.com';

            // Defensive final-form check on the filename we are about to write.
            if (!preg_match('/^[a-z0-9\-\.]+\.nfpreview\.com$/', $tempUrl)) {
                http_response_code(500);
                echo 'Internal error.';
                exit;
            }

            $baseDir    = '/etc/angie/preview-conf';
            $configPath = $baseDir . '/' . $tempUrl . '.conf';
            $realBase   = realpath($baseDir);
            $realParent = realpath(dirname($configPath));
            if ($realBase === false || $realParent !== $realBase) {
                http_response_code(500);
                echo 'Internal error.';
                exit;
            }

            // Bracket IPv6 literals for nginx proxy_pass.
            $ipForProxy = (strpos($ip, ':') !== false) ? "[{$ip}]" : $ip;

            $configTemplate = <<<CONFIG
server {
    listen 443 ssl;
    server_name {$tempUrl};

    ssl_certificate /etc/angie/ssl/angie.crt;
    ssl_certificate_key /etc/angie/ssl/angie.key;

    location / {
        proxy_pass https://{$ipForProxy};
        proxy_buffering on;
        proxy_buffers 16 4k;
        proxy_set_header Host "{$finalDomain}";
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
        proxy_set_header Accept-Encoding "";
        proxy_redirect https://{$finalDomain} https://{$tempUrl};
    }

    sub_filter 'https://{$finalDomain}' 'https://{$tempUrl}';
    sub_filter_once off;
    sub_filter_types *;
}
CONFIG;

            if (file_put_contents($configPath, $configTemplate, LOCK_EX) === false) {
                http_response_code(500);
                echo 'Failed to write preview configuration.';
                exit;
            }

            if (testAndReloadConfig($configPath)) {
                $safeTempUrl = htmlspecialchars($tempUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
                $response = "<h5><a href=\"https://{$safeTempUrl}\" target=\"_blank\" rel=\"noopener noreferrer\">https://{$safeTempUrl}</a></h5>";
            } else {
                $response = 'Failed to reload configuration or test errors occurred.';
            }
        }
    }

    echo $response;
    exit;
}
?>

<!doctype html>
<html lang="en" class="h-100" data-bs-theme="auto">
  <head><script src="color-mode.js"></script>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>NFPreview - Preview Your Website</title>
    <link rel="canonical" href="https://nfpreview.com/">
        <meta property="og:title" content="Preview Your Website">
        <meta property="og:site_name" content="NFPreview">
        <meta property="og:url" content="nfpreview.com">
        <meta property="og:description" content="Instantly preview your website changes before going live. Test updates, troubleshoot issues, and ensure a seamless experience for your visitors without modifying your DNS or host files.">
        <meta property="og:type" content="website">
        <meta property="og:image" content="https://nfpreview.com/nfpreview.jpg">
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
        <link href="style.css" rel="stylesheet">
        <link rel="preconnect" href="https://fonts.googleapis.com">
        <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
        <link href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@700&display=swap" rel="stylesheet">
  </head>
  <body class="d-flex h-100 text-center">
    <svg xmlns="http://www.w3.org/2000/svg" class="d-none">
      <symbol id="check2" viewBox="0 0 16 16">
        <path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
      </symbol>
      <symbol id="circle-half" viewBox="0 0 16 16">
        <path d="M8 15A7 7 0 1 0 8 1v14zm0 1A8 8 0 1 1 8 0a8 8 0 0 1 0 16z"/>
      </symbol>
      <symbol id="moon-stars-fill" viewBox="0 0 16 16">
        <path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
        <path d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162c.173.518.579.924 1.097 1.097l1.162.387a.217.217 0 0 1 0 .412l-1.162.387a1.734 1.734 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.734 1.734 0 0 0 9.31 6.593l-1.162-.387a.217.217 0 0 1 0-.412l1.162-.387a1.734 1.734 0 0 0 1.097-1.097l.387-1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258a1.156 1.156 0 0 0-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a1.156 1.156 0 0 0-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617-.386.732-.732L13.863.1z"/>
      </symbol>
      <symbol id="sun-fill" viewBox="0 0 16 16">
        <path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"/>
      </symbol>
    </svg>

    <div class="dropdown position-fixed bottom-0 end-0 mb-3 me-3 bd-mode-toggle">
      <button class="btn btn-bd-primary py-2 dropdown-toggle d-flex align-items-center"
              id="bd-theme"
              type="button"
              aria-expanded="false"
              data-bs-toggle="dropdown"
              aria-label="Toggle theme (auto)">
        <svg class="bi my-1 theme-icon-active" width="1em" height="1em"><use href="#circle-half"></use></svg>
        <span class="visually-hidden" id="bd-theme-text">Toggle theme</span>
      </button>
      <ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="bd-theme-text">
        <li>
          <button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="light" aria-pressed="false">
            <svg class="bi me-2 opacity-50" width="1em" height="1em"><use href="#sun-fill"></use></svg>
            Light
            <svg class="bi ms-auto d-none" width="1em" height="1em"><use href="#check2"></use></svg>
          </button>
        </li>
        <li>
          <button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark" aria-pressed="false">
            <svg class="bi me-2 opacity-50" width="1em" height="1em"><use href="#moon-stars-fill"></use></svg>
            Dark
            <svg class="bi ms-auto d-none" width="1em" height="1em"><use href="#check2"></use></svg>
          </button>
        </li>
        <li>
          <button type="button" class="dropdown-item d-flex align-items-center active" data-bs-theme-value="auto" aria-pressed="true">
            <svg class="bi me-2 opacity-50" width="1em" height="1em"><use href="#circle-half"></use></svg>
            Auto
            <svg class="bi ms-auto d-none" width="1em" height="1em"><use href="#check2"></use></svg>
          </button>
        </li>
      </ul>
    </div>

    
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
  <header class="mb-auto">
    <div>
      <h3 class="float-md-start mb-0 logo"><span class="logo-1">NF</span><span class="logo-2">Preview</span></h3>
      <nav class="nav nav-masthead justify-content-center float-md-end">
        <a class="nav-link fw-bold py-1 px-0 active" aria-current="page" href="#">Home</a>
        <a class="nav-link fw-bold py-1 px-0" href="privacy.php">Privacy Policy</a>
      </nav>
    </div>
  </header>

  <main class="px-3">
    <h1>Preview Your Website</h1>
    <p class="lead">Instantly preview your website changes before going live. Test updates, troubleshoot issues, and ensure a seamless experience for your visitors without modifying your DNS or host files.</p>
    <p class="lead mt-5">
<form id="tempUrlForm" method="post">
    <input type="hidden" name="csrf" value="<?php echo htmlspecialchars($csrfToken, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>">
    <div class="row mb-4">
        <div class="col-md-6 mb-3">
            <div class="form-floating">
                <input type="text" class="form-control form-control-lg" id="domain" name="domain" placeholder="domain.com" required>
                <label for="domain">Domain Name</label>
            </div>
        </div>
        <div class="col-md-6">
            <div class="form-floating">
                <input type="text" class="form-control form-control-lg" id="ip" name="ip" placeholder="Server IP" required>
                <label for="ip">Server IP</label>
            </div>
        </div>
    </div>
    <button type="submit" class="btn btn-lg btn-preview fw-bold">
        <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-magic" viewBox="0 0 16 16">
            <path d="M9.5 2.672a.5.5 0 1 0 1 0V.843a.5.5 0 0 0-1 0zm4.5.035A.5.5 0 0 0 13.293 2L12 3.293a.5.5 0 1 0 .707.707zM7.293 4A.5.5 0 1 0 8 3.293L6.707 2A.5.5 0 0 0 6 2.707zm-.621 2.5a.5.5 0 1 0 0-1H4.843a.5.5 0 1 0 0 1zm8.485 0a.5.5 0 1 0 0-1h-1.829a.5.5 0 0 0 0 1zM13.293 10A.5.5 0 1 0 14 9.293L12.707 8a.5.5 0 1 0-.707.707zM9.5 11.157a.5.5 0 0 0 1 0V9.328a.5.5 0 0 0-1 0zm1.854-5.097a.5.5 0 0 0 0-.706l-.708-.708a.5.5 0 0 0-.707 0L8.646 5.94a.5.5 0 0 0 0 .707l.708.708a.5.5 0 0 0 .707 0l1.293-1.293Zm-3 3a.5.5 0 0 0 0-.706l-.708-.708a.5.5 0 0 0-.707 0L.646 13.94a.5.5 0 0 0 0 .707l.708.708a.5.5 0 0 0 .707 0z"/>
        </svg> Create Preview Link
    </button>
</form>
        <div class="alert alert-light" id="loading"><span class="spinner-border spinner-border-sm"></span> Generating Preview Link... Please wait.</div>
        <div id="output"><?php echo $response; // already HTML-escaped where user-controlled ?></div>
    </p>
        <p>Website preview link expires after <mark>3</mark> days</p>
		<p><span class="logo"><span class="logo-1">NoFrills</span><span class="logo-2">Cloud</span></span> customers enjoy <strong>extended access</strong> for <mark>30</mark> days</p>
  </main>

  <footer class="mt-auto">
        <p><small><a href="https://nofrillscloud.com/cloud/" target="_blank">cPanel Cloud Hosting</a> | <a href="https://nofrillscloud.com/cloud-reseller/" target="_blank">Cloud Reseller Hosting</a> | <a href="https://nofrillscloud.com/managed-wordpress/" target="_blank">Managed WordPress Hosting</a> | <a href="https://nofrillscloud.com/managed-cloud/" target="_blank">Managed Cloud Server</a></small></p>
  </footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>

    <script src="nfpreview.js"></script>

    </body>
</html>
