Własny klucz API

This commit is contained in:
Flatlogic Bot 2026-04-11 20:29:24 +00:00
parent eefa4c490d
commit 12a4fd9735
3 changed files with 226 additions and 25 deletions

127
admin.php
View File

@ -41,6 +41,37 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
header('Location: /admin.php');
exit;
}
if ($action === 'save-openai-api-key') {
if (!diagnostic_admin_is_authenticated()) {
diagnostic_flash_set('warning', 'Zaloguj się jako administrator, aby zapisać klucz OpenAI.');
header('Location: /admin.php');
exit;
}
$openAiApiKey = preg_replace('/\s+/', '', (string)($_POST['openai_api_key'] ?? '')) ?? '';
if (!diagnostic_openai_api_key_is_valid($openAiApiKey)) {
diagnostic_flash_set('danger', 'Podaj poprawny klucz API OpenAI. Klucz powinien być kompletny i bez spacji.');
header('Location: /admin.php');
exit;
}
diagnostic_admin_setting_set('openai_api_key', $openAiApiKey);
diagnostic_flash_set('success', 'Własny klucz API OpenAI został zapisany. Nowe zapytania AI będą używały tego klucza.');
header('Location: /admin.php');
exit;
}
if ($action === 'remove-openai-api-key') {
if (!diagnostic_admin_is_authenticated()) {
diagnostic_flash_set('warning', 'Zaloguj się jako administrator, aby usunąć klucz OpenAI.');
header('Location: /admin.php');
exit;
}
diagnostic_admin_setting_set('openai_api_key', null);
diagnostic_flash_set('info', 'Zapisany klucz API OpenAI został usunięty. System wrócił do domyślnej konfiguracji proxy.');
header('Location: /admin.php');
exit;
}
if ($action === 'resend-report') {
if (!diagnostic_admin_is_authenticated()) {
diagnostic_flash_set('warning', 'Zaloguj się jako administrator, aby ponownie wysłać raport.');
@ -91,6 +122,7 @@ if (!$selectedAttempt && !empty($attempts)) {
$questionMap = diagnostic_question_map();
$credentials = diagnostic_admin_credentials();
$notificationConfig = diagnostic_admin_is_authenticated() ? diagnostic_admin_notification_config() : ['configured_email' => '', 'fallback_email' => '', 'effective_email' => '', 'source' => 'none'];
$openAiConfig = diagnostic_admin_is_authenticated() ? diagnostic_admin_openai_key_config() : ['configured' => false, 'masked_key' => ''];
?>
<!doctype html>
<html lang="pl">
@ -183,36 +215,81 @@ $notificationConfig = diagnostic_admin_is_authenticated() ? diagnostic_admin_not
<div class="col-sm-6 col-lg-3"><div class="metric-card"><strong><?= (int)$stats['average_score'] ?>%</strong><span>Średni wynik</span></div></div>
</section>
<section class="surface-card p-4 mb-4">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<div class="eyebrow mb-2">Powiadomienia o konsultacji</div>
<h2 class="h4 mb-2">Adres administratora dla nowych numerów telefonu</h2>
<p class="text-secondary mb-3">Gdy użytkownik poda numer telefonu po ukończeniu diagnozy, system wyśle powiadomienie e-mail właśnie na ten adres.</p>
<form method="post" class="row g-3" novalidate>
<input type="hidden" name="action" value="save-notification-email">
<div class="col-md-8 col-lg-7">
<label for="notificationEmail" class="form-label">Adres e-mail administratora</label>
<input type="email" class="form-control" id="notificationEmail" name="notification_email" placeholder="np. kontakt@twojafirma.pl" value="<?= htmlspecialchars((string)$notificationConfig['configured_email']) ?>" required>
<section class="row g-4 mb-4">
<div class="col-xl-6">
<div class="surface-card p-4 h-100">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<div class="eyebrow mb-2">Powiadomienia o konsultacji</div>
<h2 class="h4 mb-2">Adres administratora dla nowych numerów telefonu</h2>
<p class="text-secondary mb-3">Gdy użytkownik poda numer telefonu po ukończeniu diagnozy, system wyśle powiadomienie e-mail właśnie na ten adres.</p>
<form method="post" class="row g-3" novalidate>
<input type="hidden" name="action" value="save-notification-email">
<div class="col-md-8">
<label for="notificationEmail" class="form-label">Adres e-mail administratora</label>
<input type="email" class="form-control" id="notificationEmail" name="notification_email" placeholder="np. kontakt@twojafirma.pl" value="<?= htmlspecialchars((string)$notificationConfig['configured_email']) ?>" required>
</div>
<div class="col-md-4 d-grid align-self-end">
<button type="submit" class="btn btn-dark">Zapisz adres</button>
</div>
</form>
</div>
<div class="col-md-4 col-lg-3 d-grid align-self-end">
<button type="submit" class="btn btn-dark">Zapisz adres</button>
</div>
</form>
</div>
<div class="col-lg-5">
<div class="compact-card h-100">
<strong>Aktywna konfiguracja</strong>
<div class="col-lg-5">
<div class="compact-card h-100">
<strong>Aktywna konfiguracja</strong>
<?php if (($notificationConfig['source'] ?? 'none') === 'panel'): ?>
<span>Powiadomienia trafiają na: <strong><?= htmlspecialchars((string)$notificationConfig['effective_email']) ?></strong></span>
<span class="text-secondary">Źródło: ustawienie zapisane w panelu administracyjnym.</span>
<span>Powiadomienia trafiają na: <strong><?= htmlspecialchars((string)$notificationConfig['effective_email']) ?></strong></span>
<span class="text-secondary">Źródło: ustawienie zapisane w panelu administracyjnym.</span>
<?php elseif (($notificationConfig['source'] ?? 'none') === 'env'): ?>
<span>Powiadomienia tymczasowo trafią na: <strong><?= htmlspecialchars((string)$notificationConfig['effective_email']) ?></strong></span>
<span class="text-secondary">To fallback testowy z <code>MAIL_TO</code>. Ustaw adres w panelu, aby zarządzać nim bez edycji środowiska.</span>
<span>Powiadomienia tymczasowo trafią na: <strong><?= htmlspecialchars((string)$notificationConfig['effective_email']) ?></strong></span>
<span class="text-secondary">To fallback testowy z <code>MAIL_TO</code>. Ustaw adres w panelu, aby zarządzać nim bez edycji środowiska.</span>
<?php else: ?>
<span>Brak ustawionego adresu do powiadomień.</span>
<span class="text-secondary">Do czasu zapisania adresu w panelu e-mail o nowej prośbie o kontakt nie zostanie wysłany.</span>
<span>Brak ustawionego adresu do powiadomień.</span>
<span class="text-secondary">Do czasu zapisania adresu w panelu e-mail o nowej prośbie o kontakt nie zostanie wysłany.</span>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-6">
<div class="surface-card p-4 h-100">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<div class="eyebrow mb-2">Integracja AI</div>
<h2 class="h4 mb-2">Własny klucz API OpenAI</h2>
<p class="text-secondary mb-3">Zapisany tutaj klucz będzie automatycznie używany przez wszystkie obecne wywołania AI w aplikacji: diagnozę, czat i webhook Telegrama.</p>
<form method="post" class="row g-3" novalidate>
<input type="hidden" name="action" value="save-openai-api-key">
<div class="col-12">
<label for="openAiApiKey" class="form-label">Klucz API OpenAI</label>
<input type="password" class="form-control" id="openAiApiKey" name="openai_api_key" placeholder="wklej pełny klucz, np. sk-proj-..." value="" autocomplete="new-password" spellcheck="false">
<div class="form-text">Pole pozostaje puste po zapisaniu ze względów bezpieczeństwa. Wprowadź nowy klucz tylko wtedy, gdy chcesz go zmienić.</div>
</div>
<div class="col-sm-6 d-grid">
<button type="submit" class="btn btn-dark">Zapisz klucz</button>
</div>
</form>
<?php if (!empty($openAiConfig['configured'])): ?>
<form method="post" class="mt-3" onsubmit="return confirm('Usunąć zapisany klucz API OpenAI?');">
<input type="hidden" name="action" value="remove-openai-api-key">
<button type="submit" class="btn btn-outline-dark">Usuń zapisany klucz</button>
</form>
<?php endif; ?>
</div>
<div class="col-lg-5">
<div class="compact-card h-100">
<strong>Aktywna konfiguracja</strong>
<?php if (!empty($openAiConfig['configured'])): ?>
<span>Zapytania AI używają własnego klucza zapisanego w panelu.</span>
<span class="text-secondary">Zapisany klucz: <strong><?= htmlspecialchars((string)$openAiConfig['masked_key']) ?></strong></span>
<span class="text-secondary">Klucz jest wysyłany centralnie przez <code>LocalAIApi</code>, więc nie trzeba osobno zmieniać endpointów.</span>
<?php else: ?>
<span>Brak własnego klucza OpenAI w panelu.</span>
<span class="text-secondary">Do czasu zapisania klucza aplikacja korzysta z domyślnej konfiguracji proxy.</span>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>

View File

@ -28,6 +28,11 @@ class LocalAIApi
/** @var array<string,mixed>|null */
private static ?array $configCache = null;
/** @var string|null */
private static ?string $customApiKeyCache = null;
private static bool $customApiKeyLoaded = false;
/**
* Signature compatible with the OpenAI Responses API.
*
@ -150,6 +155,7 @@ class LocalAIApi
}
}
}
$headers = self::withCustomAuthorizationHeader($headers);
if (!empty($projectUuid) && !array_key_exists('project_uuid', $payload)) {
$payload['project_uuid'] = $projectUuid;
@ -273,6 +279,7 @@ class LocalAIApi
}
}
}
$headers = self::withCustomAuthorizationHeader($headers);
return self::sendCurl($url, 'GET', null, $headers, $timeout, $verifyTls);
}
@ -365,6 +372,71 @@ class LocalAIApi
return self::$configCache;
}
/**
* Append the admin-provided Authorization header when a custom OpenAI key is configured.
*
* @param array<int,string> $headers
* @return array<int,string>
*/
private static function withCustomAuthorizationHeader(array $headers): array
{
$customApiKey = self::customApiKey();
if ($customApiKey === null) {
return $headers;
}
foreach ($headers as $index => $header) {
if (stripos($header, 'Authorization:') === 0) {
unset($headers[$index]);
}
}
$headers[] = 'Authorization: Bearer ' . $customApiKey;
return array_values($headers);
}
private static function customApiKey(): ?string
{
if (self::$customApiKeyLoaded) {
return self::$customApiKeyCache;
}
self::$customApiKeyLoaded = true;
self::$customApiKeyCache = null;
$dbConfigPath = __DIR__ . '/../db/config.php';
if (!function_exists('db')) {
if (!file_exists($dbConfigPath)) {
return null;
}
require_once $dbConfigPath;
}
if (!function_exists('db')) {
return null;
}
try {
$pdo = db();
$stmt = $pdo->prepare('SELECT setting_value FROM diagnostic_settings WHERE setting_key = :setting_key LIMIT 1');
$stmt->execute([':setting_key' => 'openai_api_key']);
$value = $stmt->fetchColumn();
if (!is_string($value)) {
return null;
}
$normalized = preg_replace('/\s+/', '', trim($value)) ?? '';
if ($normalized === '' || strlen($normalized) < 20) {
return null;
}
self::$customApiKeyCache = $normalized;
return self::$customApiKeyCache;
} catch (Throwable $exception) {
return null;
}
}
/**
* Build an absolute URL from base_url and a path.
*/

View File

@ -1042,6 +1042,58 @@ function diagnostic_admin_notification_email(): ?string
return $config['effective_email'] !== '' ? $config['effective_email'] : null;
}
function diagnostic_mask_secret(string $value, int $visibleTail = 4): string
{
$normalized = preg_replace('/\s+/', '', trim($value)) ?? '';
if ($normalized === '') {
return '';
}
$length = function_exists('mb_strlen') ? mb_strlen($normalized) : strlen($normalized);
if ($length <= $visibleTail) {
return str_repeat('•', max($length, 8));
}
$tail = function_exists('mb_substr') ? mb_substr($normalized, -$visibleTail) : substr($normalized, -$visibleTail);
return str_repeat('•', max($length - $visibleTail, 8)) . $tail;
}
function diagnostic_openai_api_key_is_valid(string $value): bool
{
$normalized = preg_replace('/\s+/', '', trim($value)) ?? '';
if ($normalized === '' || strlen($normalized) < 20) {
return false;
}
return preg_match('/^[A-Za-z0-9_\-]+$/', $normalized) === 1;
}
function diagnostic_admin_openai_key_config(): array
{
$configuredKey = diagnostic_admin_setting_get('openai_api_key') ?? '';
$configuredKey = preg_replace('/\s+/', '', $configuredKey) ?? '';
if ($configuredKey !== '' && !diagnostic_openai_api_key_is_valid($configuredKey)) {
$configuredKey = '';
}
return [
'configured' => $configuredKey !== '',
'masked_key' => $configuredKey !== '' ? diagnostic_mask_secret($configuredKey) : '',
];
}
function diagnostic_admin_openai_api_key(): ?string
{
$config = diagnostic_admin_openai_key_config();
if (empty($config['configured'])) {
return null;
}
$key = diagnostic_admin_setting_get('openai_api_key') ?? '';
$key = preg_replace('/\s+/', '', $key) ?? '';
return $key !== '' ? $key : null;
}
function diagnostic_send_consultation_notification(array $attempt): array
{
$recipient = diagnostic_admin_notification_email();