<?php
#-------------------------------------------------------------------------
# Plugin: mas_api (MAS_API)
# Secure outbound HTTP from Smarty for CMS Made Simple 2.x
# License: GPL-2.0-or-later
#-------------------------------------------------------------------------

if (!defined('CMS_ROOT_PATH')) {
    exit;
}

$__mas_api_lib = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'mas_api' . DIRECTORY_SEPARATOR;
require_once $__mas_api_lib . 'MasApi_UrlPolicy.php';
require_once $__mas_api_lib . 'MasApi_Credentials.php';
require_once $__mas_api_lib . 'MasApi_RateLimiter.php';
require_once $__mas_api_lib . 'MasApi_CacheStore.php';
require_once $__mas_api_lib . 'MasApi_JsonHelper.php';
require_once $__mas_api_lib . 'MasApi_RequestExecutor.php';

/**
 * @param string $msg
 * @return void
 */
function mas_api_plugin_log($msg)
{
    if (function_exists('error_log')) {
        error_log('[mas_api] ' . $msg);
    }
}

/**
 * @param bool $trustXff
 * @return string
 */
function mas_api_client_key($trustXff)
{
    if ($trustXff && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
        $parts = explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR']);

        return trim($parts[0]);
    }

    return isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '0';
}

/**
 * @param string $pluginsDir
 * @return array|null
 */
function mas_api_load_allowlist($pluginsDir)
{
    $file = rtrim($pluginsDir, '/\\') . DIRECTORY_SEPARATOR . 'mas_api.allowlist.php';
    if (!is_file($file)) {
        return null;
    }
    $data = @include $file;
    if (!is_array($data)) {
        return null;
    }
    $out = [
        'hosts' => [],
        'suffixes' => [],
        'ports' => [],
    ];
    if (isset($data['hosts']) && is_array($data['hosts'])) {
        foreach ($data['hosts'] as $h) {
            if (is_string($h) && $h !== '') {
                $out['hosts'][] = strtolower(trim($h));
            }
        }
    }
    if (isset($data['suffixes']) && is_array($data['suffixes'])) {
        foreach ($data['suffixes'] as $s) {
            if (is_string($s) && $s !== '') {
                $out['suffixes'][] = strtolower(trim($s));
            }
        }
    }
    if (isset($data['ports']) && is_array($data['ports'])) {
        foreach ($data['ports'] as $p) {
            $out['ports'][] = (int) $p;
        }
    }

    return $out;
}

/**
 * @param array<string,mixed> $params
 * @return array<string,string>
 */
function mas_api_collect_template_headers($params)
{
    $headers = [];
    foreach ($params as $k => $v) {
        if (!is_string($k) || strpos($k, 'header_') !== 0) {
            continue;
        }
        if (!is_scalar($v)) {
            continue;
        }
        $name = str_replace('_', '-', substr($k, 7));
        if ($name !== '') {
            $headers[$name] = (string) $v;
        }
    }

    return $headers;
}

/**
 * Merge profile headers then template headers (profile wins on duplicate keys, case-insensitive).
 *
 * @param array<string,string> $profile
 * @param array<string,string> $template
 * @return array<string,string>
 */
function mas_api_merge_headers($profile, $template)
{
    $out = [];
    foreach ($template as $k => $v) {
        $out[$k] = $v;
    }
    foreach ($profile as $k => $v) {
        $found = false;
        foreach (array_keys($out) as $ek) {
            if (strcasecmp((string) $ek, (string) $k) === 0) {
                unset($out[$ek]);
                $out[$k] = $v;
                $found = true;
                break;
            }
        }
        if (!$found) {
            $out[$k] = $v;
        }
    }

    return $out;
}

/**
 * @param array<string,string> $headers
 * @return string
 */
function mas_api_headers_fingerprint(array $headers)
{
    ksort($headers);
    $flat = '';
    foreach ($headers as $k => $v) {
        $flat .= strtolower($k) . "\n" . $v . "\n";
    }

    return hash('sha256', $flat);
}

/**
 * @param array<string,mixed> $params
 * @param Smarty_Internal_Template|Smarty $smarty
 * @return string
 */
function smarty_nocache_function_mas_api($params, &$smarty)
{
    $pluginsDir = dirname(__FILE__);
    $assign = isset($params['assign']) ? (string) $params['assign'] : '';
    $assignStatus = isset($params['assign_status']) ? (string) $params['assign_status'] : '';
    $assignError = isset($params['assign_error']) ? (string) $params['assign_error'] : '';

    $setAssign = static function ($smarty, $name, $value) {
        if ($name === '') {
            return;
        }
        if (method_exists($smarty, 'assign')) {
            $smarty->assign($name, $value);
        }
    };

    $fail = static function ($smarty, $code, $assign, $assignError, $setAssign) {
        $setAssign($smarty, $assign, null);
        if ($assignError !== '') {
            $setAssign($smarty, $assignError, $code);
        }
    };

    $url = isset($params['url']) ? trim((string) $params['url']) : '';
    $method = isset($params['method']) ? strtoupper(trim((string) $params['method'])) : 'GET';
    $cacheTtl = isset($params['cache']) ? (int) $params['cache'] : 0;
    $timeout = isset($params['timeout']) ? (int) $params['timeout'] : 10;
    $timeout = max(1, min(30, $timeout));
    $maxResponse = isset($params['max_response']) ? (int) $params['max_response'] : 524288;
    $maxResponse = max(1024, min(2097152, $maxResponse));
    $rateLimit = isset($params['rate_limit']) ? (int) $params['rate_limit'] : 0;
    $rateWindow = isset($params['rate_window']) ? (int) $params['rate_window'] : 60;
    $profile = isset($params['profile']) ? trim((string) $params['profile']) : '';
    $signProfile = isset($params['sign_profile']) ? trim((string) $params['sign_profile']) : '';
    $allowInsecure = !empty($params['allow_insecure_http']);
    $trustXff = !empty($params['trust_x_forwarded_for']);
    $parseJson = !empty($params['parse_json']);
    if (!$parseJson && isset($params['json']) && ((string) $params['json'] === '1' || $params['json'] === 1)) {
        $parseJson = true;
    }
    $bodyParam = null;
    if (isset($params['body'])) {
        $bodyParam = $params['body'];
    } elseif (isset($params['json'])) {
        $jv = $params['json'];
        if ($jv === '1' || $jv === 1) {
            $bodyParam = null;
        } else {
            $bodyParam = $jv;
        }
    }

    $userAgent = isset($params['user_agent']) ? (string) $params['user_agent'] : 'CMSMS-MAS_API/1.0';

    $allowlistData = mas_api_load_allowlist($pluginsDir);
    if ($allowlistData === null) {
        mas_api_plugin_log('blocked: missing mas_api.allowlist.php (fail closed)');
        $fail($smarty, 'no_allowlist', $assign, $assignError, $setAssign);

        return '';
    }

    $policy = new MasApi_UrlPolicy($allowlistData, $allowInsecure);
    if (!$policy->isAllowlistConfigured()) {
        mas_api_plugin_log('blocked: empty allowlist (hosts and suffixes)');
        $fail($smarty, 'empty_allowlist', $assign, $assignError, $setAssign);

        return '';
    }

    if ($url === '') {
        $fail($smarty, 'missing_url', $assign, $assignError, $setAssign);

        return '';
    }

    $hostCheck = @parse_url($url, PHP_URL_HOST);
    $hostForRl = is_string($hostCheck) ? strtolower($hostCheck) : 'unknown';

    if (!defined('TMP_CACHE_LOCATION')) {
        mas_api_plugin_log('TMP_CACHE_LOCATION undefined; skipping cache and rate limit');
        $cacheBase = null;
    } else {
        $cacheBase = cms_join_path(TMP_CACHE_LOCATION, 'mas_api');
        if (!is_dir($cacheBase)) {
            @mkdir($cacheBase, 0700, true);
        }
        if (!is_file(cms_join_path($cacheBase, 'index.html'))) {
            @file_put_contents(cms_join_path($cacheBase, 'index.html'), '<!DOCTYPE html><title></title>');
        }
    }

    $credLoader = new MasApi_Credentials($pluginsDir);
    $loadedCred = $credLoader->load();
    $profileHeaders = $credLoader->mergeProfileHeaders($profile, $loadedCred);
    $templateHeaders = mas_api_collect_template_headers($params);
    $headers = mas_api_merge_headers($profileHeaders, $templateHeaders);

    $norm = MasApi_JsonHelper::normalizeRequestBody($bodyParam);
    if (!$norm['ok']) {
        $fail($smarty, $norm['error'], $assign, $assignError, $setAssign);

        return '';
    }
    $rawBody = $norm['raw'];

    if ($signProfile !== '') {
        $secret = $credLoader->getSigningSecret($signProfile, $loadedCred);
        if ($secret === null) {
            $fail($smarty, 'unknown_sign_profile', $assign, $assignError, $setAssign);

            return '';
        }
        $sig = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
        $headers['X-Hub-Signature-256'] = $sig;
    }

    if ($rawBody !== '' && $method !== 'GET') {
        $hasCt = false;
        foreach (array_keys($headers) as $hk) {
            if (strcasecmp($hk, 'Content-Type') === 0) {
                $hasCt = true;
                break;
            }
        }
        if (!$hasCt) {
            $headers['Content-Type'] = 'application/json; charset=utf-8';
        }
    }

    $bodyPrint = hash('sha256', $rawBody);
    $hf = mas_api_headers_fingerprint($headers);
    $cacheKey = hash(
        'sha256',
        $method . "\n" . $url . "\n" . $bodyPrint . "\n" . $hf . "\n" . $profile . "\n" . $signProfile
    );

    if ($cacheBase !== null && $rateLimit > 0) {
        $rl = new MasApi_RateLimiter($cacheBase);
        $ip = mas_api_client_key($trustXff);
        if (!$rl->allow($ip, $hostForRl, $rateLimit, $rateWindow)) {
            mas_api_plugin_log('rate_limited client=' . $ip . ' host=' . $hostForRl);
            $fail($smarty, 'rate_limited', $assign, $assignError, $setAssign);

            return '';
        }
    }

    if ($cacheBase !== null && $cacheTtl > 0) {
        $cacheStore = new MasApi_CacheStore($cacheBase);
        $cached = $cacheStore->get($cacheKey, $cacheTtl);
        if (!empty($cached['hit'])) {
            $bodyOut = $cached['body'];
            if ($parseJson) {
                $dec = MasApi_JsonHelper::decodeResponse($bodyOut, $maxResponse);
                if (!$dec['ok']) {
                    $fail($smarty, $dec['error'], $assign, $assignError, $setAssign);
                    if ($assignStatus !== '') {
                        $setAssign($smarty, $assignStatus, isset($cached['status']) ? (int) $cached['status'] : 0);
                    }

                    return '';
                }
                $setAssign($smarty, $assign, $dec['data']);
            } else {
                $setAssign($smarty, $assign, $bodyOut);
            }
            if ($assignStatus !== '') {
                $setAssign($smarty, $assignStatus, isset($cached['status']) ? (int) $cached['status'] : 200);
            }

            return '';
        }
    }

    $exec = new MasApi_RequestExecutor($policy, 5);
    $res = $exec->execute($url, $method, $headers, $rawBody, $timeout, $maxResponse, $userAgent);

    if (empty($res['ok'])) {
        $err = isset($res['error']) ? (string) $res['error'] : 'request_failed';
        mas_api_plugin_log('request error=' . $err . ' host=' . $hostForRl);
        if (function_exists('audit')) {
            @audit('', 'mas_api', 'Request failed: ' . $err . ' host=' . $hostForRl);
        }
        $fail($smarty, $err, $assign, $assignError, $setAssign);
        if ($assignStatus !== '') {
            $setAssign($smarty, $assignStatus, isset($res['http_code']) ? (int) $res['http_code'] : 0);
        }

        return '';
    }

    $bodyOut = isset($res['body']) ? (string) $res['body'] : '';
    $httpCode = isset($res['http_code']) ? (int) $res['http_code'] : 0;

    if ($cacheBase !== null && $cacheTtl > 0 && $httpCode >= 200 && $httpCode < 300) {
        (new MasApi_CacheStore($cacheBase))->set($cacheKey, $cacheTtl, $bodyOut, $httpCode);
    }

    if ($parseJson) {
        $dec = MasApi_JsonHelper::decodeResponse($bodyOut, $maxResponse);
        if (!$dec['ok']) {
            $fail($smarty, $dec['error'], $assign, $assignError, $setAssign);
            if ($assignStatus !== '') {
                $setAssign($smarty, $assignStatus, $httpCode);
            }

            return '';
        }
        $setAssign($smarty, $assign, $dec['data']);
    } else {
        $setAssign($smarty, $assign, $bodyOut);
    }
    if ($assignStatus !== '') {
        $setAssign($smarty, $assignStatus, $httpCode);
    }

    return '';
}

/**
 * Help text for Extensions > Tags
 *
 * @return void
 */
function smarty_cms_help_function_mas_api()
{
    echo '<h3>MAS_API</h3>';
    echo '<p>Secure outbound HTTP(S) from templates. Requires <code>mas_api.allowlist.php</code> in the same folder as this plugin (fail closed if missing or empty).</p>';
    echo '<h4>Example</h4>';
    echo '<pre>{mas_api url="https://api.example.com/v1/status" method="GET" cache="300" parse_json="1" assign="api_data"}</pre>';
    echo '<h4>Parameters</h4>';
    echo '<ul>';
    echo '<li><strong>url</strong> (required): HTTPS URL (HTTP only if <code>allow_insecure_http="1"</code>).</li>';
    echo '<li><strong>method</strong>: GET, POST, PUT, DELETE (default GET).</li>';
    echo '<li><strong>assign</strong>: Smarty variable for response body (string) or decoded JSON when parse_json is set.</li>';
    echo '<li><strong>parse_json</strong>: Decode JSON response (also <code>json="1"</code> for decode flag).</li>';
    echo '<li><strong>body</strong> / <strong>json</strong>: Request body (string or array for JSON).</li>';
    echo '<li><strong>cache</strong>: Seconds to cache successful response body under tmp (0 disables).</li>';
    echo '<li><strong>timeout</strong>: Seconds (1 to 30, default 10).</li>';
    echo '<li><strong>rate_limit</strong> / <strong>rate_window</strong>: Max requests per IP per target host per window (requires <code>TMP_CACHE_LOCATION</code>).</li>';
    echo '<li><strong>profile</strong>: Named headers from server file <code>mas_api.credentials.php</code> (never put secrets in templates).</li>';
    echo '<li><strong>sign_profile</strong>: HMAC SHA256 of raw body as <code>X-Hub-Signature-256</code> using secret from credentials file.</li>';
    echo '<li><strong>header_Name_With_Dashes</strong>: Use keys like <code>header_X_Custom_Header="value"</code> (underscores become hyphens after <code>header_</code>).</li>';
    echo '<li><strong>assign_status</strong>: Variable to receive HTTP status code.</li>';
    echo '<li><strong>assign_error</strong>: Short error code when the request is blocked or fails.</li>';
    echo '<li><strong>max_response</strong>: Max response bytes (1024 to 2097152, default 524288).</li>';
    echo '<li><strong>trust_x_forwarded_for</strong>: Use first X-Forwarded-For for rate limiting (off by default).</li>';
    echo '</ul>';
    echo '<p><strong>Inbound webhooks</strong> are not handled here; use a module action or endpoint.</p>';
}

/**
 * About line for Extensions > Tags
 *
 * @return void
 */
function smarty_cms_about_function_mas_api()
{
    echo '<p>MAS_API 1.0.0 for CMS Made Simple 2.x. Outbound REST helper with allowlist, SSRF checks, cache, rate limit, and optional HMAC signing.</p>';
}
