require __DIR__ . '/vendor/autoload.php'; use Slim\Factory\AppFactory; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; $app = AppFactory::create(); $app->addBodyParsingMiddleware(); $app->addErrorMiddleware(true, true, true); // ============================================================================= // CONFIG // ============================================================================= date_default_timezone_set('Asia/Phnom_Penh'); $ENABLE_LOGS = false; $BAKONG_BASE_URL = 'https://api-bakong.nbc.gov.kh'; $TOKEN_FILE = __DIR__ . '/../storage/token.json'; $BAKONG_EMAIL = 'tevadatevada100@gmail.com'; // ๐Ÿ” API Key Guard โ€” leave '' to disable $GATEWAY_API_KEY = ''; // โฑ Rate Limiter $RATE_LIMIT_MAX = 60; $RATE_LIMIT_WINDOW = 60; $RATE_LIMIT_DIR = __DIR__ . '/../storage/rate-limits'; // ๐Ÿ’พ Response Cache $CACHE_TTL = 30; $CACHE_DIR = __DIR__ . '/../storage/cache'; // ๐Ÿ”” Webhook Forwarder $WEBHOOK_FORWARD_URL = ''; $WEBHOOK_SECRET = ''; // ๐Ÿ”’ IP Whitelist โ€” empty array = allow ALL IPs $IP_WHITELIST = []; // e.g. ['127.0.0.1', '203.0.113.5'] // ๐Ÿ”’ HMAC Request Signing โ€” signs every outgoing Bakong request $HMAC_SECRET = ''; // leave '' to disable // ๐Ÿ“Š Stats & Audit Log $STATS_FILE = __DIR__ . '/../storage/stats.json'; $AUDIT_LOG = __DIR__ . '/../logs/audit.log'; // ๐Ÿ”” Token Alert โ€” sends a webhook when token is about to expire $TOKEN_ALERT_DAYS = 7; // alert when < N days remain $TOKEN_ALERT_WEBHOOK = ''; // POST this URL with expiry info // ============================================================================= // TOKEN FUNCTIONS // ============================================================================= function getBakongToken(string $file): string { if (!file_exists($file)) return ''; $data = json_decode(file_get_contents($file), true); return $data['token'] ?? ''; } function getJwtExpiry(string $token): int { $parts = explode('.', $token); if (count($parts) !== 3) return 0; $payload = json_decode(base64_decode(str_pad( strtr($parts[1], '-_', '+/'), strlen($parts[1]) + (4 - strlen($parts[1]) % 4) % 4, '=' )), true); return isset($payload['exp']) ? (int) $payload['exp'] : 0; } function isTokenExpired(string $token, int $bufferSeconds = 300): bool { if (empty($token)) return true; $exp = getJwtExpiry($token); return $exp === 0 || time() >= ($exp - $bufferSeconds); } function renewBakongToken(string $baseUrl, string $email, string $tokenFile, bool $enableLogs): string { $logFile = dirname($tokenFile, 2) . '/logs/renew-token.log'; $logFn = function (string $msg) use ($logFile, $enableLogs) { if (!$enableLogs) return; file_put_contents($logFile, '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL, FILE_APPEND); }; $ch = curl_init($baseUrl . '/v1/renew_token'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['email' => $email]), CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_TIMEOUT => 30, ]); $result = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); if ($curlErr) { $logFn('โŒ CURL ERROR: ' . $curlErr); return ''; } $data = json_decode($result, true); if ($httpCode !== 200 || !isset($data['responseCode']) || $data['responseCode'] !== 0 || empty($data['data']['token'])) { $logFn('โŒ Renewal failed. HTTP ' . $httpCode); return ''; } $newToken = $data['data']['token']; $expTs = 0; $pl = explode('.', $newToken); if (count($pl) === 3) { $dec = json_decode(base64_decode(str_pad(strtr($pl[1], '-_', '+/'), strlen($pl[1]) + (4 - strlen($pl[1]) % 4) % 4, '=')), true); $expTs = isset($dec['exp']) ? (int) $dec['exp'] : 0; } file_put_contents($tokenFile, json_encode([ 'token' => $newToken, 'updated_at' => date('c'), 'expires_at' => $expTs > 0 ? date('c', $expTs) : null, ], JSON_PRETTY_PRINT)); $logFn('โœ… Token renewed'); return $newToken; } // ============================================================================= // SECURITY FUNCTIONS // ============================================================================= function checkApiKey(Request $request, string $key): bool { if (empty($key)) return true; return $request->getHeaderLine('X-Api-Key') === $key; } function checkRateLimit(string $ip, string $dir, int $max, int $window): bool { if (!is_dir($dir)) mkdir($dir, 0755, true); $file = $dir . '/' . md5($ip) . '.json'; $now = time(); $state = file_exists($file) ? (json_decode(file_get_contents($file), true) ?? ['count' => 0, 'window_start' => $now]) : ['count' => 0, 'window_start' => $now]; if ($now - $state['window_start'] >= $window) $state = ['count' => 0, 'window_start' => $now]; if ($state['count'] >= $max) return false; $state['count']++; file_put_contents($file, json_encode($state), LOCK_EX); return true; } /** Check if IP is in whitelist (empty list = allow all). */ function checkIpWhitelist(string $ip, array $whitelist): bool { if (empty($whitelist)) return true; return in_array($ip, $whitelist, true); } /** Build HMAC-SHA256 signature for an outgoing request body. */ function buildHmacSignature(string $body, string $secret): string { return hash_hmac('sha256', $body, $secret); } // ============================================================================= // REQUEST TRACKING & AUDIT // ============================================================================= function getOrCreateRequestId(Request $request): string { $id = $request->getHeaderLine('X-Request-ID'); return !empty($id) ? $id : 'req_' . bin2hex(random_bytes(8)); } function writeAuditLog(string $file, array $entry): void { $dir = dirname($file); if (!is_dir($dir)) mkdir($dir, 0755, true); file_put_contents($file, json_encode($entry) . PHP_EOL, FILE_APPEND | LOCK_EX); } // ============================================================================= // RESPONSE CACHE // ============================================================================= function cacheKey(string $url, string $body): string { return md5($url . $body); } function getCachedResponse(string $key, string $dir): ?array { $file = $dir . '/' . $key . '.json'; if (!file_exists($file)) return null; $data = json_decode(file_get_contents($file), true); if (!$data || time() > $data['expires_at']) { @unlink($file); return null; } return $data; } function setCachedResponse(string $key, string $dir, string $body, int $status, int $ttl): void { if (!is_dir($dir)) mkdir($dir, 0755, true); file_put_contents($dir . '/' . $key . '.json', json_encode([ 'body' => $body, 'status' => $status, 'expires_at' => time() + $ttl, 'cached_at' => date('c'), ]), LOCK_EX); } // ============================================================================= // STATS // ============================================================================= function updateStats(string $statsFile, string $path, int $ms, bool $cacheHit, bool $isError): void { $dir = dirname($statsFile); if (!is_dir($dir)) mkdir($dir, 0755, true); $s = file_exists($statsFile) ? (json_decode(file_get_contents($statsFile), true) ?? []) : []; $total = ($s['total_requests'] ?? 0) + 1; $s['total_requests'] = $total; if ($isError) $s['total_errors'] = ($s['total_errors'] ?? 0) + 1; if ($cacheHit) $s['total_cache_hits'] = ($s['total_cache_hits'] ?? 0) + 1; $prev = $s['avg_response_ms'] ?? 0; $s['avg_response_ms'] = round(($prev * ($total - 1) + $ms) / $total, 1); $clean = ltrim(explode('?', $path)[0], '/') ?: 'root'; $s['endpoints'][$clean] = ($s['endpoints'][$clean] ?? 0) + 1; $s['started_at'] = $s['started_at'] ?? date('c'); $s['last_updated'] = date('c'); file_put_contents($statsFile, json_encode($s, JSON_PRETTY_PRINT), LOCK_EX); } // ============================================================================= // RETRY WITH EXPONENTIAL BACKOFF // ============================================================================= function retryRequest(callable $doReq, string $method, string $url, string $body, array $headers): array { foreach ([0, 1, 2, 4] as $i => $delay) { if ($delay > 0) sleep($delay); [$result, $status, $err] = $doReq($method, $url, $body, $headers); if (!$err && ($status < 500 || $status >= 600)) return [$result, $status, $err]; if ($i === 3) return [$result, $status, $err]; } return ['', 0, 'Retry exhausted']; } // ============================================================================= // LOG HELPER // ============================================================================= function getLastLines(string $file, int $n = 100): array { if (!file_exists($file)) return []; $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); return array_slice($lines, -$n); } // ============================================================================= // TOKEN ALERT // ============================================================================= function checkTokenAlert(string $token, int $alertDays, string $webhookUrl, string $tokenFile): void { if (empty($webhookUrl)) return; $exp = getJwtExpiry($token); if ($exp === 0) return; $daysLeft = ($exp - time()) / 86400; if ($daysLeft > $alertDays) return; $sentFile = dirname($tokenFile) . '/alert_sent.json'; if (file_exists($sentFile)) { $sent = json_decode(file_get_contents($sentFile), true); if (isset($sent['sent_at']) && (time() - strtotime($sent['sent_at'])) < 86400) return; } $payload = json_encode(['event' => 'token.expiring_soon', 'days_left' => round($daysLeft, 1), 'expires_at' => date('c', $exp), 'sent_at' => date('c')]); $ch = curl_init($webhookUrl); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_TIMEOUT => 5]); curl_exec($ch); file_put_contents($sentFile, json_encode(['sent_at' => date('c'), 'days_left' => round($daysLeft, 1)])); } // ============================================================================= // CORS // ============================================================================= $app->add(function (Request $request, $handler) { $response = $request->getMethod() === 'OPTIONS' ? new \Slim\Psr7\Response() : $handler->handle($request); return $response ->withHeader('Access-Control-Allow-Origin', '*') ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS') ->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Api-Key, X-Request-ID'); }); // ============================================================================= // ROOT // ============================================================================= $app->get('/', function (Request $request, Response $response) { $response->getBody()->write(json_encode([ 'status' => 'ok', 'message' => 'Bakong OpenAPI Auto Gateway v3', 'docs' => '/bakong/docs', 'status_check' => '/bakong/status', ], JSON_PRETTY_PRINT)); return $response->withHeader('Content-Type', 'application/json'); }); // ============================================================================= // 1. /bakong/status โ€” HEALTH CHECK + TOKEN ALERT // ============================================================================= $app->get('/bakong/status', function (Request $request, Response $response) use ($BAKONG_BASE_URL, $TOKEN_FILE, $TOKEN_ALERT_DAYS, $TOKEN_ALERT_WEBHOOK) { $token = getBakongToken($TOKEN_FILE); $exp = getJwtExpiry($token); $isExpired = isTokenExpired($token); $secondsLeft = $exp > 0 ? $exp - time() : 0; $expiresIn = null; if ($exp > 0 && $secondsLeft > 0) { $d = floor($secondsLeft / 86400); $h = floor(($secondsLeft % 86400) / 3600); $expiresIn = $d > 0 ? "{$d} days" : "{$h} hours"; } $ping = curl_init($BAKONG_BASE_URL . '/v1/check_transaction_by_md5'); curl_setopt_array($ping, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['md5' => 'healthcheck']), CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_TIMEOUT => 5]); curl_exec($ping); $pingCode = curl_getinfo($ping, CURLINFO_HTTP_CODE); $reachable = !curl_error($ping) && $pingCode > 0; // Trigger token alert if needed checkTokenAlert($token, $TOKEN_ALERT_DAYS, $TOKEN_ALERT_WEBHOOK, $TOKEN_FILE); $response->getBody()->write(json_encode([ 'status' => 'ok', 'token_valid' => !$isExpired, 'expires_at' => $exp > 0 ? date('c', $exp) : null, 'expires_in' => $expiresIn, 'days_left' => $exp > 0 ? round($secondsLeft / 86400, 1) : null, 'bakong_reachable' => $reachable, 'bakong_ping_code' => $pingCode ?: null, 'alert_threshold' => "{$TOKEN_ALERT_DAYS} days", 'checked_at' => date('c'), ], JSON_PRETTY_PRINT)); return $response->withHeader('Content-Type', 'application/json'); }); // ============================================================================= // 2. /bakong/khqr โ€” KHQR DEEP LINK + MD5 GENERATOR // ============================================================================= $app->post('/bakong/khqr', function (Request $request, Response $response) use ($GATEWAY_API_KEY) { if (!checkApiKey($request, $GATEWAY_API_KEY)) { $response->getBody()->write(json_encode(['status' => 'error', 'message' => 'Unauthorized: invalid X-Api-Key'])); return $response->withStatus(401)->withHeader('Content-Type', 'application/json'); } $body = $request->getParsedBody() ?? []; $merchantName = $body['merchant_name'] ?? ''; $accountId = $body['account_id'] ?? ''; // e.g. merchant@wing $amount = $body['amount'] ?? 0; $currency = strtoupper($body['currency'] ?? 'KHR'); $reference = $body['reference'] ?? ('TXN-' . strtoupper(bin2hex(random_bytes(4)))); if (empty($accountId) || $amount <= 0) { $response->getBody()->write(json_encode([ 'status' => 'error', 'message' => 'account_id and amount are required', 'example' => ['merchant_name' => 'My Shop', 'account_id' => 'merchant@wing', 'amount' => 5000, 'currency' => 'KHR', 'reference' => 'INV-001'], ])); return $response->withStatus(400)->withHeader('Content-Type', 'application/json'); } // KHQR deep link (Bakong app format) $deepLinkParams = http_build_query(['merchantName' => $merchantName, 'accountId' => $accountId, 'amount' => $amount, 'currency' => $currency, 'reference' => $reference]); $deepLink = 'bakong://payment?' . $deepLinkParams; // MD5 for later polling via /bakong/poll or /v1/check_transaction_by_md5 $md5 = md5($amount . $currency . $reference . $accountId); $response->getBody()->write(json_encode([ 'status' => 'ok', 'deep_link' => $deepLink, 'md5' => $md5, 'reference' => $reference, 'merchant_name'=> $merchantName, 'account_id' => $accountId, 'amount' => $amount, 'currency' => $currency, 'poll_tip' => 'Use POST /bakong/poll with this md5 to confirm payment', ], JSON_PRETTY_PRINT)); return $response->withHeader('Content-Type', 'application/json'); }); // ============================================================================= // 3. /bakong/poll โ€” TRANSACTION POLLER // ============================================================================= $app->post('/bakong/poll', function (Request $request, Response $response) use ($BAKONG_BASE_URL, $TOKEN_FILE, $ENABLE_LOGS, $BAKONG_EMAIL, $GATEWAY_API_KEY) { if (!checkApiKey($request, $GATEWAY_API_KEY)) { $response->getBody()->write(json_encode(['status' => 'error', 'message' => 'Unauthorized'])); return $response->withStatus(401)->withHeader('Content-Type', 'application/json'); } $body = $request->getParsedBody() ?? []; $md5 = trim($body['md5'] ?? ''); $maxTries = min((int) ($body['max_tries'] ?? 10), 30); // cap 30 $interval = min((int) ($body['interval_seconds'] ?? 2), 10); // cap 10s if (empty($md5)) { $response->getBody()->write(json_encode(['status' => 'error', 'message' => 'md5 is required', 'example' => ['md5' => 'abc123...', 'max_tries' => 10, 'interval_seconds' => 2]])); return $response->withStatus(400)->withHeader('Content-Type', 'application/json'); } set_time_limit(($maxTries * $interval) + 60); $accessToken = getBakongToken($TOKEN_FILE); if (isTokenExpired($accessToken)) { $accessToken = renewBakongToken($BAKONG_BASE_URL, $BAKONG_EMAIL, $TOKEN_FILE, $ENABLE_LOGS); } $found = false; $lastData = null; $attempt = 0; while ($attempt < $maxTries && !$found) { $attempt++; $ch = curl_init($BAKONG_BASE_URL . '/v1/check_transaction_by_md5'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $accessToken], CURLOPT_POSTFIELDS => json_encode(['md5' => $md5]), CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_TIMEOUT => 10, ]); $result = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); // Auto-renew on 401 mid-poll if ($httpCode === 401) { $accessToken = renewBakongToken($BAKONG_BASE_URL, $BAKONG_EMAIL, $TOKEN_FILE, $ENABLE_LOGS); } $data = json_decode($result, true); $lastData = $data; if ($httpCode === 200 && isset($data['responseCode']) && $data['responseCode'] === 0 && !empty($data['data'])) { $found = true; break; } if ($attempt < $maxTries) sleep($interval); } $response->getBody()->write(json_encode([ 'status' => $found ? 'confirmed' : 'timeout', 'found' => $found, 'md5' => $md5, 'attempts' => $attempt, 'max_tries' => $maxTries, 'interval_s' => $interval, 'bakong_data' => $lastData, ], JSON_PRETTY_PRINT)); return $response->withHeader('Content-Type', 'application/json'); }); // ============================================================================= // 4. /bakong/batch/md5 โ€” BATCH TRANSACTION CHECKER // ============================================================================= $app->post('/bakong/batch/md5', function (Request $request, Response $response) use ($BAKONG_BASE_URL, $TOKEN_FILE, $ENABLE_LOGS, $BAKONG_EMAIL, $GATEWAY_API_KEY) { if (!checkApiKey($request, $GATEWAY_API_KEY)) { $response->getBody()->write(json_encode(['status' => 'error', 'message' => 'Unauthorized'])); return $response->withStatus(401)->withHeader('Content-Type', 'application/json'); } $body = $request->getParsedBody() ?? []; $hashes = $body['hashes'] ?? []; if (empty($hashes) || !is_array($hashes)) { $response->getBody()->write(json_encode(['status' => 'error', 'message' => 'Provide a "hashes" array of MD5 strings', 'example' => ['hashes' => ['md5_1', 'md5_2', 'md5_3']]])); return $response->withStatus(400)->withHeader('Content-Type', 'application/json'); } $hashes = array_slice($hashes, 0, 50); // cap at 50 $accessToken = getBakongToken($TOKEN_FILE); if (isTokenExpired($accessToken)) $accessToken = renewBakongToken($BAKONG_BASE_URL, $BAKONG_EMAIL, $TOKEN_FILE, $ENABLE_LOGS); $results = []; foreach ($hashes as $md5) { $ch = curl_init($BAKONG_BASE_URL . '/v1/check_transaction_by_md5'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $accessToken], CURLOPT_POSTFIELDS => json_encode(['md5' => $md5]), CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_TIMEOUT => 10, ]); $result = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($httpCode === 401) { $accessToken = renewBakongToken($BAKONG_BASE_URL, $BAKONG_EMAIL, $TOKEN_FILE, $ENABLE_LOGS); } $data = json_decode($result, true); $results[$md5] = [ 'http_code' => $httpCode, 'found' => isset($data['responseCode']) && $data['responseCode'] === 0 && !empty($data['data']), 'data' => $data, ]; } $found = count(array_filter($results, fn($r) => $r['found'])); $response->getBody()->write(json_encode([ 'status' => 'ok', 'total' => count($results), 'found' => $found, 'not_found' => count($results) - $found, 'results' => $results, ], JSON_PRETTY_PRINT)); return $response->withHeader('Content-Type', 'application/json'); }); // ============================================================================= // 5. /bakong/md5 โ€” SINGLE MD5 HASH HELPER // ============================================================================= $app->post('/bakong/md5', function (Request $request, Response $response) use ($GATEWAY_API_KEY) { if (!checkApiKey($request, $GATEWAY_API_KEY)) { $response->getBody()->write(json_encode(['status' => 'error', 'message' => 'Unauthorized'])); return $response->withStatus(401)->withHeader('Content-Type', 'application/json'); } $body = $request->getParsedBody() ?? []; $fields = $body['fields'] ?? null; if (!$fields || !is_array($fields) || count($fields) === 0) { $response->getBody()->write(json_encode(['status' => 'error', 'message' => 'Provide a "fields" array', 'example' => ['fields' => ['1000', 'USD', 'INV-001', 'merchant123']]])); return $response->withStatus(400)->withHeader('Content-Type', 'application/json'); } $concatenated = implode('', array_map('strval', $fields)); $response->getBody()->write(json_encode([ 'status' => 'ok', 'input_fields' => $fields, 'concatenated' => $concatenated, 'md5' => md5($concatenated), ], JSON_PRETTY_PRINT)); return $response->withHeader('Content-Type', 'application/json'); }); // ============================================================================= // 6. /bakong/webhook โ€” WEBHOOK FORWARDER // ============================================================================= $app->post('/bakong/webhook', function (Request $request, Response $response) use ($WEBHOOK_FORWARD_URL, $WEBHOOK_SECRET, $ENABLE_LOGS) { $logFile = __DIR__ . '/../logs/webhook.log'; $log = function (string $msg) use ($logFile, $ENABLE_LOGS) { if (!$ENABLE_LOGS) return; file_put_contents($logFile, '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL, FILE_APPEND); }; $rawBody = (string) $request->getBody(); $signature = $request->getHeaderLine('X-Bakong-Signature'); if (!empty($WEBHOOK_SECRET) && !empty($signature)) { if (!hash_equals(hash_hmac('sha256', $rawBody, $WEBHOOK_SECRET), $signature)) { $log('โŒ Signature mismatch'); $response->getBody()->write(json_encode(['status' => 'error', 'message' => 'Invalid signature'])); return $response->withStatus(401)->withHeader('Content-Type', 'application/json'); } } $log('๐Ÿ“ฉ Webhook: ' . $rawBody); if (!empty($WEBHOOK_FORWARD_URL)) { $ch = curl_init($WEBHOOK_FORWARD_URL); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rawBody, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Forwarded-By: BakongGateway', 'X-Bakong-Signature: ' . $signature], CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2]); $fwdCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_exec($ch); $log('โžก๏ธ Forwarded โ†’ HTTP ' . $fwdCode); } $response->getBody()->write(json_encode(['status' => 'received', 'forwarded' => !empty($WEBHOOK_FORWARD_URL), 'timestamp' => date('c')])); return $response->withStatus(200)->withHeader('Content-Type', 'application/json'); }); // ============================================================================= // 7. /bakong/echo โ€” REQUEST ECHO (debug) // ============================================================================= $app->map(['GET', 'POST'], '/bakong/echo', function (Request $request, Response $response) { $body = (string) $request->getBody(); $response->getBody()->write(json_encode([ 'method' => $request->getMethod(), 'uri' => (string) $request->getUri(), 'headers' => $request->getHeaders(), 'body' => !empty($body) ? (json_decode($body, true) ?? $body) : null, 'query' => $request->getQueryParams(), 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', 'time' => date('c'), ], JSON_PRETTY_PRINT)); return $response->withHeader('Content-Type', 'application/json'); }); // ============================================================================= // 8. /bakong/logs โ€” LOG VIEWER // ============================================================================= $app->get('/bakong/logs', function (Request $request, Response $response) use ($GATEWAY_API_KEY) { if (!checkApiKey($request, $GATEWAY_API_KEY)) { $response->getBody()->write(json_encode(['status' => 'error', 'message' => 'Unauthorized'])); return $response->withStatus(401)->withHeader('Content-Type', 'application/json'); } $params = $request->getQueryParams(); $logName = preg_replace('/[^a-z\-]/', '', $params['file'] ?? 'proxy'); $lines = min((int) ($params['lines'] ?? 50), 500); $allowed = ['proxy', 'audit', 'renew-token', 'webhook']; if (!in_array($logName, $allowed)) { $response->getBody()->write(json_encode(['status' => 'error', 'message' => 'Invalid log file', 'allowed' => $allowed])); return $response->withStatus(400)->withHeader('Content-Type', 'application/json'); } $file = __DIR__ . '/../logs/' . $logName . '.log'; $content = getLastLines($file, $lines); // Parse audit log lines as JSON objects if possible if ($logName === 'audit') { $content = array_map(fn($l) => json_decode($l, true) ?? $l, $content); } $response->getBody()->write(json_encode([ 'log' => $logName, 'file' => $logName . '.log', 'showing' => count($content), 'lines' => $content, ], JSON_PRETTY_PRINT)); return $response->withHeader('Content-Type', 'application/json'); }); // ============================================================================= // 9. /bakong/stats โ€” STATS DASHBOARD // ============================================================================= $app->get('/bakong/stats', function (Request $request, Response $response) use ($GATEWAY_API_KEY, $STATS_FILE) { if (!checkApiKey($request, $GATEWAY_API_KEY)) { $response->getBody()->write(json_encode(['status' => 'error', 'message' => 'Unauthorized'])); return $response->withStatus(401)->withHeader('Content-Type', 'application/json'); } $s = file_exists($STATS_FILE) ? (json_decode(file_get_contents($STATS_FILE), true) ?? []) : []; $total = $s['total_requests'] ?? 0; $s['cache_hit_rate'] = $total > 0 ? round((($s['total_cache_hits'] ?? 0) / $total) * 100, 1) . '%' : '0%'; $s['error_rate'] = $total > 0 ? round((($s['total_errors'] ?? 0) / $total) * 100, 1) . '%' : '0%'; if (isset($s['endpoints'])) { arsort($s['endpoints']); // sort by most used } $response->getBody()->write(json_encode($s, JSON_PRETTY_PRINT)); return $response->withHeader('Content-Type', 'application/json'); }); // ============================================================================= // 10. /bakong/config โ€” CONFIG VIEWER (secrets masked) // ============================================================================= $app->get('/bakong/config', function (Request $request, Response $response) use ( $GATEWAY_API_KEY, $BAKONG_BASE_URL, $BAKONG_EMAIL, $ENABLE_LOGS, $RATE_LIMIT_MAX, $RATE_LIMIT_WINDOW, $CACHE_TTL, $IP_WHITELIST, $HMAC_SECRET, $WEBHOOK_FORWARD_URL, $TOKEN_ALERT_DAYS, $TOKEN_ALERT_WEBHOOK, $TOKEN_FILE ) { if (!checkApiKey($request, $GATEWAY_API_KEY)) { $response->getBody()->write(json_encode(['status' => 'error', 'message' => 'Unauthorized'])); return $response->withStatus(401)->withHeader('Content-Type', 'application/json'); } $token = getBakongToken($TOKEN_FILE); $exp = getJwtExpiry($token); $mask = fn($v) => !empty($v) ? substr($v, 0, 4) . str_repeat('*', max(4, strlen($v) - 4)) : 'disabled'; $response->getBody()->write(json_encode([ 'bakong_base_url' => $BAKONG_BASE_URL, 'bakong_email' => $BAKONG_EMAIL, 'enable_logs' => $ENABLE_LOGS, 'api_key' => !empty($GATEWAY_API_KEY) ? $mask($GATEWAY_API_KEY) : 'disabled', 'rate_limit' => "{$RATE_LIMIT_MAX} req / {$RATE_LIMIT_WINDOW}s", 'cache_ttl' => "{$CACHE_TTL}s", 'ip_whitelist' => !empty($IP_WHITELIST) ? $IP_WHITELIST : 'disabled (all IPs)', 'hmac_signing' => !empty($HMAC_SECRET) ? 'enabled' : 'disabled', 'webhook_forward' => !empty($WEBHOOK_FORWARD_URL) ? $WEBHOOK_FORWARD_URL : 'disabled', 'webhook_secret' => !empty($WEBHOOK_FORWARD_URL) ? $mask($WEBHOOK_SECRET ?? '') : 'disabled', 'token_alert_days' => $TOKEN_ALERT_DAYS, 'token_alert_webhook' => !empty($TOKEN_ALERT_WEBHOOK) ? $TOKEN_ALERT_WEBHOOK : 'disabled', 'token_expires_at' => $exp > 0 ? date('c', $exp) : 'no token', 'token_days_left' => $exp > 0 ? round(($exp - time()) / 86400, 1) : null, ], JSON_PRETTY_PRINT)); return $response->withHeader('Content-Type', 'application/json'); }); // ============================================================================= // 11. /bakong/docs โ€” INTERACTIVE HTML DOCUMENTATION // ============================================================================= $app->get('/bakong/docs', function (Request $request, Response $response) { $html = <<<'HTML' Bakong Gateway v3 โ€” API Docs

Bakong API Gateway v3.0

Auto-renewing ยท Self-securing ยท Full-featured proxy for Bakong NBC Open API

โšก System
GET/bakong/statuspublicHealth check

Returns token validity, expiry countdown, and Bakong API connectivity.

Response
{"status":"ok","token_valid":true,"expires_at":"2026-09-12T...","expires_in":"89 days","days_left":89.0,"bakong_reachable":true,"bakong_ping_code":401,"checked_at":"..."}
GET/bakong/configX-Api-KeyConfig viewer

Shows current gateway configuration. Secrets are masked.

Response
{"bakong_base_url":"https://api-bakong.nbc.gov.kh","rate_limit":"60 req / 60s","cache_ttl":"30s","ip_whitelist":"disabled (all IPs)",...}
GET/bakong/statsX-Api-KeyStats dashboard

Total requests, error rate, cache hit rate, avg response time, top endpoints.

Response
{"total_requests":150,"total_errors":5,"total_cache_hits":30,"avg_response_ms":320.1,"cache_hit_rate":"20.0%","error_rate":"3.3%","endpoints":{...}}
GET/bakong/logs?file=proxy&lines=50X-Api-KeyLog viewer
ParamTypeDefaultOptions
filestringproxyproxy, audit, renew-token, webhook
linesint501โ€“500
๐Ÿฆ Transaction Helpers
POST/bakong/khqrX-Api-KeyGenerate KHQR deep link

Generates a bakong://payment deep link and MD5 hash for payment confirmation.

Request Body
{"merchant_name":"My Shop","account_id":"merchant@wing","amount":5000,"currency":"KHR","reference":"INV-001"}
Response
{"status":"ok","deep_link":"bakong://payment?merchantName=My+Shop&accountId=merchant%40wing&amount=5000&currency=KHR&reference=INV-001","md5":"abc123...","reference":"INV-001"}
๐Ÿ’ก Use the returned md5 with POST /bakong/poll to confirm payment.
POST/bakong/pollX-Api-KeyPoll until payment confirmed

Polls Bakong every N seconds until the transaction is found or max tries is reached.

FieldTypeDescription
md5stringTransaction MD5 hash required
max_triesintMax poll attempts (default: 10, max: 30)
interval_secondsintSeconds between polls (default: 2, max: 10)
Response
{"status":"confirmed","found":true,"md5":"abc123","attempts":3,"max_tries":10,"interval_s":2,"bakong_data":{...}}
POST/bakong/batch/md5X-Api-KeyCheck multiple transactions
Request Body
{"hashes":["md5_hash_1","md5_hash_2","md5_hash_3"]}
Response
{"status":"ok","total":3,"found":2,"not_found":1,"results":{"md5_hash_1":{"found":true,"data":{...}},...}}
โš ๏ธ Maximum 50 hashes per request.
POST/bakong/md5X-Api-KeyGenerate single MD5 hash
Request Body
{"fields":["1000","USD","INV-001","merchant123"]}
Response
{"status":"ok","input_fields":["1000","USD","INV-001","merchant123"],"concatenated":"1000USDINV-001merchant123","md5":"51e1f4ab..."}
๐Ÿ”— Webhooks & Debug
POST/bakong/webhookpublicReceive Bakong callbacks

Accepts Bakong payment callbacks. Optionally verifies X-Bakong-Signature and forwards to your backend URL.

POST/bakong/echopublicEcho request (debug)

Returns all request details: method, headers, body, query params, IP. Useful for debugging.

๐Ÿ”€ Proxy
ANY/{bakong_path}X-Api-KeyTransparent Bakong proxy

Transparently proxies any request to the Bakong API with auto token injection, renewal, retry, and caching.

FeatureBehaviour
TokenAuto-injected + auto-renewed on expiry or 401
Retry5xx errors retried with 1sโ†’2sโ†’4s backoff
CacheSuccessful GET responses cached for 30s
Rate Limit60 req/min per IP (429 + Retry-After)
X-Request-IDAdded to every request and response
Audit LogEvery request logged to logs/audit.log
Example
POST /v1/check_transaction_by_md5
X-Api-Key: your-secret
Content-Type: application/json

{"md5": "your_transaction_md5"}
HTML; $response->getBody()->write($html); return $response->withHeader('Content-Type', 'text/html; charset=utf-8'); }); // ============================================================================= // 12. CRON RENEW TOKEN // ============================================================================= $app->get('/bakong/renew/cron', function (Request $request, Response $response) use ($BAKONG_BASE_URL, $TOKEN_FILE, $ENABLE_LOGS, $BAKONG_EMAIL) { $params = $request->getQueryParams(); $cronKey = ''; // Set your cron key here if (!isset($params['key']) || $params['key'] !== $cronKey) { $response->getBody()->write(json_encode(['status' => 'error', 'message' => 'Invalid or missing cron key'])); return $response->withStatus(403)->withHeader('Content-Type', 'application/json'); } $newToken = renewBakongToken($BAKONG_BASE_URL, $BAKONG_EMAIL, $TOKEN_FILE, $ENABLE_LOGS); if (empty($newToken)) { $response->getBody()->write(json_encode(['status' => 'error', 'message' => 'Failed to renew token'])); return $response->withStatus(502)->withHeader('Content-Type', 'application/json'); } if ($ENABLE_LOGS) file_put_contents(__DIR__ . '/../logs/renew-token.log', '[' . date('Y-m-d H:i:s') . '] CRON RENEWAL SUCCESS' . PHP_EOL, FILE_APPEND); $response->getBody()->write(json_encode(['status' => 'success', 'message' => 'Token renewed', 'renewed_at' => date('c')])); return $response->withHeader('Content-Type', 'application/json'); }); // ============================================================================= // PROXY โ€” with IP Whitelist + HMAC Signing + Audit Log + Stats // ============================================================================= $app->map(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], '/{path:.*}', function ( Request $request, Response $response, array $args ) use ( $BAKONG_BASE_URL, $TOKEN_FILE, $ENABLE_LOGS, $BAKONG_EMAIL, $GATEWAY_API_KEY, $RATE_LIMIT_MAX, $RATE_LIMIT_WINDOW, $RATE_LIMIT_DIR, $CACHE_TTL, $CACHE_DIR, $IP_WHITELIST, $HMAC_SECRET, $STATS_FILE, $AUDIT_LOG ) { $startTime = microtime(true); // Logging helper $logFile = __DIR__ . '/../logs/proxy.log'; $log = function ($msg, $data = null) use ($logFile, $ENABLE_LOGS) { if (!$ENABLE_LOGS) return; $e = '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL; if ($data !== null) $e .= (is_string($data) ? $data : json_encode($data, JSON_PRETTY_PRINT)) . PHP_EOL; file_put_contents($logFile, $e . str_repeat('-', 40) . PHP_EOL, FILE_APPEND); }; // Finalize helper โ€” write audit + stats before every return $finalize = function (Response $response, int $httpStatus, bool $cacheHit, string $cacheMode, string $requestId, string $ip, string $method, string $path) use ($startTime, $STATS_FILE, $AUDIT_LOG, $ENABLE_LOGS) { $ms = (int) ((microtime(true) - $startTime) * 1000); updateStats($STATS_FILE, $path, $ms, $cacheHit, $httpStatus >= 400); if ($ENABLE_LOGS) writeAuditLog($AUDIT_LOG, ['ts' => date('c'), 'req_id' => $requestId, 'ip' => $ip, 'method' => $method, 'path' => $path, 'status' => $httpStatus, 'ms' => $ms, 'cache' => $cacheMode]); return $response; }; // ๐Ÿ†” Request ID $requestId = getOrCreateRequestId($request); $clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; $method = $request->getMethod(); $path = '/' . $args['path']; $bakongUrl = $BAKONG_BASE_URL . $path; $log("๐Ÿ†” [$requestId] $method $path"); // ๐Ÿ”’ IP Whitelist if (!checkIpWhitelist($clientIp, $IP_WHITELIST)) { $log("๐Ÿšซ [$requestId] IP blocked: $clientIp"); $response->getBody()->write(json_encode(['responseCode' => 1, 'responseMessage' => 'Your IP is not allowed.', 'requestId' => $requestId])); return $finalize($response->withStatus(403)->withHeader('Content-Type', 'application/json')->withHeader('X-Request-ID', $requestId), 403, false, 'NONE', $requestId, $clientIp, $method, $path); } // ๐Ÿ” API Key if (!checkApiKey($request, $GATEWAY_API_KEY)) { $log("๐Ÿ” [$requestId] Unauthorized"); $response->getBody()->write(json_encode(['responseCode' => 1, 'responseMessage' => 'Unauthorized: provide a valid X-Api-Key header.', 'requestId' => $requestId])); return $finalize($response->withStatus(401)->withHeader('Content-Type', 'application/json')->withHeader('X-Request-ID', $requestId), 401, false, 'NONE', $requestId, $clientIp, $method, $path); } // โฑ Rate Limiter if (!checkRateLimit($clientIp, $RATE_LIMIT_DIR, $RATE_LIMIT_MAX, $RATE_LIMIT_WINDOW)) { $log("โฑ [$requestId] Rate limited: $clientIp"); $response->getBody()->write(json_encode(['responseCode' => 1, 'responseMessage' => "Rate limit exceeded: max {$RATE_LIMIT_MAX} req/{$RATE_LIMIT_WINDOW}s.", 'requestId' => $requestId])); return $finalize($response->withStatus(429)->withHeader('Content-Type', 'application/json')->withHeader('X-Request-ID', $requestId)->withHeader('Retry-After', (string) $RATE_LIMIT_WINDOW), 429, false, 'NONE', $requestId, $clientIp, $method, $path); } // Base cURL helper $doReq = function (string $method, string $url, string $body, array $headers) { $ch = curl_init($url); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_POSTFIELDS => in_array($method, ['POST', 'PUT', 'PATCH']) ? $body : null, CURLOPT_HTTPHEADER => $headers, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_TIMEOUT => 30]); $result = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); return [$result, $code, $err]; }; // Auto token $accessToken = getBakongToken($TOKEN_FILE); if (isTokenExpired($accessToken)) { $log("โš ๏ธ [$requestId] Token expired โ€” renewing..."); $accessToken = renewBakongToken($BAKONG_BASE_URL, $BAKONG_EMAIL, $TOKEN_FILE, $ENABLE_LOGS); if (empty($accessToken)) { $response->getBody()->write(json_encode(['responseCode' => 1, 'responseMessage' => 'Token unavailable.', 'requestId' => $requestId])); return $finalize($response->withStatus(503)->withHeader('Content-Type', 'application/json')->withHeader('X-Request-ID', $requestId), 503, false, 'NONE', $requestId, $clientIp, $method, $path); } } $body = (string) $request->getBody(); if (empty($body)) $body = json_encode($request->getParsedBody() ?? $request->getQueryParams()); $buildHeaders = function (string $token) use ($request, $HMAC_SECRET, $body): array { $headers = ['Content-Type: application/json']; if (!$request->hasHeader('Authorization')) $headers[] = 'Authorization: Bearer ' . $token; // ๐Ÿ”’ HMAC signing if (!empty($HMAC_SECRET)) $headers[] = 'X-Gateway-Signature: ' . buildHmacSignature($body, $HMAC_SECRET); return $headers; }; // ๐Ÿ’พ Cache (GET only) $ck = cacheKey($bakongUrl, $body); $isCacheHit = false; if ($method === 'GET') { $cached = getCachedResponse($ck, $CACHE_DIR); if ($cached) { $isCacheHit = true; $age = time() - strtotime($cached['cached_at']); $log("๐Ÿ’พ [$requestId] Cache HIT"); $response->getBody()->write($cached['body']); return $finalize( $response->withStatus($cached['status'])->withHeader('Content-Type', 'application/json')->withHeader('X-Request-ID', $requestId)->withHeader('X-Cache', 'HIT')->withHeader('X-Cache-Age', (string) $age), $cached['status'], true, 'HIT', $requestId, $clientIp, $method, $path ); } } $headers = $buildHeaders($accessToken); $log("โžก๏ธ [$requestId] $method $bakongUrl"); // ๐Ÿ”„ Request with backoff retry [$result, $status, $curlErr] = retryRequest($doReq, $method, $bakongUrl, $body, $headers); if ($curlErr) { $response->getBody()->write(json_encode(['responseCode' => 1, 'responseMessage' => 'Gateway Error', 'error' => $curlErr, 'requestId' => $requestId])); return $finalize($response->withStatus(500)->withHeader('Content-Type', 'application/json')->withHeader('X-Request-ID', $requestId), 500, false, 'BYPASS', $requestId, $clientIp, $method, $path); } $log("โฌ…๏ธ [$requestId] HTTP $status"); // Auto-renew on 401 + retry if ($status === 401) { $log("๐Ÿ”„ [$requestId] 401 โ€” renewing and retrying..."); $newToken = renewBakongToken($BAKONG_BASE_URL, $BAKONG_EMAIL, $TOKEN_FILE, $ENABLE_LOGS); if (empty($newToken)) { $response->getBody()->write(json_encode(['responseCode' => 1, 'responseMessage' => 'Token expired and renewal failed.', 'requestId' => $requestId])); return $finalize($response->withStatus(503)->withHeader('Content-Type', 'application/json')->withHeader('X-Request-ID', $requestId), 503, false, 'BYPASS', $requestId, $clientIp, $method, $path); } [$result, $status, $curlErr] = retryRequest($doReq, $method, $bakongUrl, $body, $buildHeaders($newToken)); if ($curlErr) { $response->getBody()->write(json_encode(['responseCode' => 1, 'responseMessage' => 'Gateway Error on retry', 'requestId' => $requestId])); return $finalize($response->withStatus(500)->withHeader('Content-Type', 'application/json')->withHeader('X-Request-ID', $requestId), 500, false, 'BYPASS', $requestId, $clientIp, $method, $path); } } // HTML fallback if (strpos($result, '') !== false || strpos($result, 'getBody()->write(json_encode(['responseCode' => 1, 'responseMessage' => 'Not Found', 'errorCode' => 404, 'data' => null, 'requestId' => $requestId])); return $finalize($response->withStatus(404)->withHeader('Content-Type', 'application/json')->withHeader('X-Request-ID', $requestId), 404, false, 'BYPASS', $requestId, $clientIp, $method, $path); } // Cache successful GET $cacheMode = 'BYPASS'; if ($method === 'GET' && $status === 200) { setCachedResponse($ck, $CACHE_DIR, $result, $status, $CACHE_TTL); $cacheMode = 'MISS'; $log("๐Ÿ’พ [$requestId] Cached for {$CACHE_TTL}s"); } $response->getBody()->write($result); return $finalize( $response->withStatus($status)->withHeader('Content-Type', 'application/json')->withHeader('X-Request-ID', $requestId)->withHeader('X-Cache', $cacheMode), $status, false, $cacheMode, $requestId, $clientIp, $method, $path ); }); $app->run();