cinema-studio-pro/backend/admin_api.php
Vadym Samoilenko 22a5ce83af feat: admin users can rotate Kling credentials in real-time via UI
- Add runtime_config.php: credential store backed by runtime_config.json
  (gitignored). Falls back to .env values so existing envs need no migration.
- Add admin_api.php: status / test_kling / update_kling endpoints gated
  behind ADMIN_EMAILS allowlist. Accepts Bearer idToken when SSO enabled;
  uses mock dev@localhost when SSO disabled.
- config.php: replace KLING_ACCESS_KEY/SECRET_KEY defines with ADMIN_EMAILS
- kling_api.php: read credentials via getKlingCredentials() on every request
  so rotations take effect immediately without a server restart
- All .env templates: add ADMIN_EMAILS= (dev@localhost populated in .env.local)
- AdminSettings.jsx: modal with masked status, Test Connection, Save Credentials
- AppContent.jsx: admin status check on mount; Settings gear shown to admins
- Fix production URL in .env.production/.env.example (optical-prod.oliver.solutions)
- .gitignore: exclude backend/runtime_config.json

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:45:27 +01:00

195 lines
6.6 KiB
PHP

<?php
/**
* Admin API — Kling credential rotation and status
*
* Actions (all require admin):
* GET ?action=status — masked credential info
* POST ?action=update_kling — rotate access_key + secret_key
* POST ?action=test_kling — verify credentials against Kling API
*
* Authentication:
* SSO disabled -> mock user accepted; must be in ADMIN_EMAILS
* SSO enabled -> Authorization: Bearer <idToken> required; validated via JWTValidator
*/
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/runtime_config.php';
// ---------------------------------------------------------------------------
// Auth helpers
// ---------------------------------------------------------------------------
function getRequestUser(): ?array {
if (!SSO_ENABLED) {
return ['preferred_username' => 'dev@localhost', 'name' => 'Local Developer'];
}
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (empty($authHeader) && function_exists('apache_request_headers')) {
$authHeader = apache_request_headers()['Authorization'] ?? '';
}
if (!preg_match('/^Bearer\s+(.+)$/i', trim($authHeader), $m)) {
return null;
}
require_once __DIR__ . '/JWTValidator.php';
$validator = new JWTValidator(SSO_TENANT_ID, SSO_CLIENT_ID);
$result = $validator->validateToken($m[1]);
return $result['valid'] ? $result['payload'] : null;
}
function isAdmin(?array $user): bool {
if (!$user) return false;
$list = array_filter(array_map('trim', explode(',', ADMIN_EMAILS)));
if (empty($list)) return false;
$email = strtolower(
$user['preferred_username'] ?? $user['upn'] ?? $user['email'] ?? ''
);
foreach ($list as $allowed) {
if (strtolower($allowed) === $email) return true;
}
return false;
}
function requireAdmin(): array {
$user = getRequestUser();
if (!isAdmin($user)) {
http_response_code(403);
echo json_encode(['error' => 'Admin access required']);
exit;
}
return $user;
}
// ---------------------------------------------------------------------------
// Kling test helper
// ---------------------------------------------------------------------------
function testKlingCredentials(string $accessKey, string $secretKey): array {
$header = rtrim(strtr(base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])), '+/', '-_'), '=');
$now = time();
$payload = rtrim(strtr(base64_encode(json_encode([
'iss' => $accessKey,
'exp' => $now + 1800,
'nbf' => $now - 5,
])), '+/', '-_'), '=');
$sig = rtrim(strtr(base64_encode(hash_hmac('sha256', "$header.$payload", $secretKey, true)), '+/', '-_'), '=');
$jwt = "$header.$payload.$sig";
$ch = curl_init('https://api-singapore.klingai.com/v1/videos/text2video?pageNum=1&pageSize=1');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $jwt,
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
return ['ok' => true];
}
$body = json_decode($response, true);
$error = $body['message'] ?? ($body['error'] ?? "HTTP $httpCode");
return ['ok' => false, 'error' => $error];
}
// ---------------------------------------------------------------------------
// Audit log
// ---------------------------------------------------------------------------
function appendAuditLog(string $action, array $user): void {
$dir = __DIR__ . '/uploads';
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$line = json_encode([
'ts' => gmdate('Y-m-d\TH:i:s\Z'),
'user' => $user['preferred_username'] ?? $user['email'] ?? 'unknown',
'action' => $action,
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
]) . "\n";
@file_put_contents($dir . '/admin_audit.log', $line, FILE_APPEND | LOCK_EX);
}
// ---------------------------------------------------------------------------
// Request handler
// ---------------------------------------------------------------------------
try {
$action = $_GET['action'] ?? $_POST['action'] ?? null;
if ($action === 'status') {
requireAdmin();
echo json_encode(['kling' => getKlingStatus()]);
} elseif ($action === 'test_kling') {
$user = requireAdmin();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$accessKey = trim($body['access_key'] ?? '');
$secretKey = trim($body['secret_key'] ?? '');
if ($accessKey === '' || $secretKey === '') {
$creds = getKlingCredentials();
$accessKey = $creds['access_key'];
$secretKey = $creds['secret_key'];
}
if ($accessKey === '' || $secretKey === '') {
echo json_encode(['ok' => false, 'error' => 'No credentials to test']);
} else {
echo json_encode(testKlingCredentials($accessKey, $secretKey));
}
} elseif ($action === 'update_kling') {
$user = requireAdmin();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$accessKey = trim($body['access_key'] ?? '');
$secretKey = trim($body['secret_key'] ?? '');
if (strlen($accessKey) < 8 || strlen($secretKey) < 8) {
http_response_code(400);
echo json_encode(['error' => 'Access key and secret key must each be at least 8 characters']);
exit;
}
$updatedBy = $user['preferred_username'] ?? $user['email'] ?? 'unknown';
if (!setKlingCredentials($accessKey, $secretKey, $updatedBy)) {
http_response_code(500);
echo json_encode(['error' => 'Failed to write runtime config — check file permissions on backend/']);
exit;
}
appendAuditLog('update_kling', $user);
echo json_encode(['kling' => getKlingStatus()]);
} else {
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
}
} catch (Throwable $e) {
error_log('admin_api.php error: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'Internal server error']);
}