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'
Auto-renewing ยท Self-securing ยท Full-featured proxy for Bakong NBC Open API
Returns token validity, expiry countdown, and Bakong API connectivity.
{"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":"..."}
Shows current gateway configuration. Secrets are masked.
{"bakong_base_url":"https://api-bakong.nbc.gov.kh","rate_limit":"60 req / 60s","cache_ttl":"30s","ip_whitelist":"disabled (all IPs)",...}
Total requests, error rate, cache hit rate, avg response time, top endpoints.
{"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":{...}}
| Param | Type | Default | Options |
|---|---|---|---|
| file | string | proxy | proxy, audit, renew-token, webhook |
| lines | int | 50 | 1โ500 |
Generates a bakong://payment deep link and MD5 hash for payment confirmation.
{"merchant_name":"My Shop","account_id":"merchant@wing","amount":5000,"currency":"KHR","reference":"INV-001"}
{"status":"ok","deep_link":"bakong://payment?merchantName=My+Shop&accountId=merchant%40wing&amount=5000¤cy=KHR&reference=INV-001","md5":"abc123...","reference":"INV-001"}
md5 with POST /bakong/poll to confirm payment.Polls Bakong every N seconds until the transaction is found or max tries is reached.
| Field | Type | Description |
|---|---|---|
| md5 | string | Transaction MD5 hash required |
| max_tries | int | Max poll attempts (default: 10, max: 30) |
| interval_seconds | int | Seconds between polls (default: 2, max: 10) |
{"status":"confirmed","found":true,"md5":"abc123","attempts":3,"max_tries":10,"interval_s":2,"bakong_data":{...}}
{"hashes":["md5_hash_1","md5_hash_2","md5_hash_3"]}
{"status":"ok","total":3,"found":2,"not_found":1,"results":{"md5_hash_1":{"found":true,"data":{...}},...}}
{"fields":["1000","USD","INV-001","merchant123"]}
{"status":"ok","input_fields":["1000","USD","INV-001","merchant123"],"concatenated":"1000USDINV-001merchant123","md5":"51e1f4ab..."}
Accepts Bakong payment callbacks. Optionally verifies X-Bakong-Signature and forwards to your backend URL.
Returns all request details: method, headers, body, query params, IP. Useful for debugging.
Transparently proxies any request to the Bakong API with auto token injection, renewal, retry, and caching.
| Feature | Behaviour |
|---|---|
| Token | Auto-injected + auto-renewed on expiry or 401 |
| Retry | 5xx errors retried with 1sโ2sโ4s backoff |
| Cache | Successful GET responses cached for 30s |
| Rate Limit | 60 req/min per IP (429 + Retry-After) |
| X-Request-ID | Added to every request and response |
| Audit Log | Every request logged to logs/audit.log |
POST /v1/check_transaction_by_md5
X-Api-Key: your-secret
Content-Type: application/json
{"md5": "your_transaction_md5"}