161 lines
7.9 KiB
PHP
161 lines
7.9 KiB
PHP
<?php
|
|
/**
|
|
* Where the AI settings live on disk.
|
|
*
|
|
* content/ai-settings.json everything except the keys (models, limits, rights, domains): site configuration,
|
|
* safe to keep in version control
|
|
* content/ai-secrets.json the API keys only: the provider keys, the image service keys, the Brave Search key and the Pexels key;
|
|
* never commit it (it is in .gitignore) and never serve it
|
|
*
|
|
* Readers get one array with both merged, so the rest of the code does not know about the split. Writers hand
|
|
* the whole settings array to raijuAiWriteStoredSettings(), which sends the keys to the secrets file. A file
|
|
* from an older version that still holds the keys is read as before and moved by raijuAiMigrateSecrets().
|
|
*/
|
|
|
|
require_once dirname(__DIR__) . '/json-store.php';
|
|
|
|
if (!defined('RAIJU_AI_SETTINGS_PATH')) {
|
|
define('RAIJU_AI_SETTINGS_PATH', dirname(__DIR__, 2) . '/content/ai-settings.json');
|
|
}
|
|
if (!defined('RAIJU_AI_SECRETS_PATH')) {
|
|
define('RAIJU_AI_SECRETS_PATH', dirname(RAIJU_AI_SETTINGS_PATH) . '/ai-secrets.json');
|
|
}
|
|
|
|
function raijuAiReadJsonObject(string $path): array {
|
|
if (!is_file($path)) return [];
|
|
$data = json_decode((string)file_get_contents($path), true);
|
|
return is_array($data) ? $data : [];
|
|
}
|
|
|
|
/** The stored settings with the keys merged in (keys from a legacy settings file stay valid until they are moved). */
|
|
function raijuAiReadStoredSettings(): array {
|
|
$settings = raijuAiReadJsonObject(RAIJU_AI_SETTINGS_PATH);
|
|
$secrets = raijuAiReadJsonObject(RAIJU_AI_SECRETS_PATH);
|
|
return $secrets ? array_replace_recursive($settings, $secrets) : $settings;
|
|
}
|
|
|
|
/**
|
|
* The keys of a settings array in the shape of the secrets file. Empty keys are left out.
|
|
*
|
|
* @return array{providerCredentials?:array,imageServices?:array,research?:array,stock?:array}
|
|
*/
|
|
function raijuAiExtractSecrets(array $settings): array {
|
|
$secrets = [];
|
|
$credentials = is_array($settings['providerCredentials'] ?? null) ? $settings['providerCredentials'] : [];
|
|
// A key at the top level (older files) belongs to the provider that was active.
|
|
$active = (string)($settings['provider'] ?? '');
|
|
$top = trim((string)($settings['apiKey'] ?? ''));
|
|
if ($top !== '' && $active !== '' && trim((string)($credentials[$active]['apiKey'] ?? '')) === '') {
|
|
$credentials[$active]['apiKey'] = $top;
|
|
}
|
|
foreach ($credentials as $provider => $entry) {
|
|
$key = is_array($entry) ? trim((string)($entry['apiKey'] ?? '')) : '';
|
|
if ($key !== '') $secrets['providerCredentials'][(string)$provider]['apiKey'] = $key;
|
|
}
|
|
foreach ((array)($settings['imageServices'] ?? []) as $id => $entry) {
|
|
$key = is_array($entry) ? trim((string)($entry['apiKey'] ?? '')) : '';
|
|
if ($key !== '') $secrets['imageServices'][(string)$id]['apiKey'] = $key;
|
|
}
|
|
$brave = trim((string)($settings['research']['brave']['apiKey'] ?? ''));
|
|
if ($brave !== '') $secrets['research']['brave']['apiKey'] = $brave;
|
|
$pexels = trim((string)($settings['stock']['pexels']['apiKey'] ?? ''));
|
|
if ($pexels !== '') $secrets['stock']['pexels']['apiKey'] = $pexels;
|
|
return $secrets;
|
|
}
|
|
|
|
/** The settings array without any key. */
|
|
function raijuAiStripSecrets(array $settings): array {
|
|
unset($settings['apiKey']);
|
|
if (is_array($settings['providerCredentials'] ?? null)) {
|
|
foreach ($settings['providerCredentials'] as $provider => $entry) {
|
|
if (is_array($entry)) unset($settings['providerCredentials'][$provider]['apiKey']);
|
|
}
|
|
}
|
|
unset($settings['imageServices']);
|
|
if (is_array($settings['research']['brave'] ?? null)) {
|
|
unset($settings['research']['brave']['apiKey']);
|
|
if (!$settings['research']['brave']) unset($settings['research']['brave']);
|
|
}
|
|
if (is_array($settings['stock']['pexels'] ?? null)) {
|
|
unset($settings['stock']['pexels']['apiKey']);
|
|
if (!$settings['stock']['pexels']) unset($settings['stock']['pexels']);
|
|
if (!$settings['stock']) unset($settings['stock']);
|
|
}
|
|
return $settings;
|
|
}
|
|
|
|
/** Write the secrets file: owner-only where the system allows it, replaced atomically. */
|
|
function raijuAiWriteSecrets(array $secrets): bool {
|
|
if (!$secrets && !is_file(RAIJU_AI_SECRETS_PATH)) return true;
|
|
if (!raijuJsonAtomicWrite(RAIJU_AI_SECRETS_PATH, $secrets)) return false;
|
|
@chmod(RAIJU_AI_SECRETS_PATH, 0600);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Store the settings: the keys in the secrets file, the rest in the settings file. The secrets go first, so a
|
|
* failure never leaves a settings file without the keys it used to hold.
|
|
*/
|
|
function raijuAiWriteStoredSettings(array $settings): bool {
|
|
if (!raijuAiWriteSecrets(raijuAiExtractSecrets($settings))) return false;
|
|
$dir = dirname(RAIJU_AI_SETTINGS_PATH);
|
|
if (!is_dir($dir)) @mkdir($dir, 0755, true);
|
|
$json = json_encode(raijuAiStripSecrets($settings), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
return $json !== false && file_put_contents(RAIJU_AI_SETTINGS_PATH, $json, LOCK_EX) !== false;
|
|
}
|
|
|
|
/**
|
|
* Move keys that an older version left in the settings file into the secrets file. Does nothing when there
|
|
* are none. Safe to call often.
|
|
*/
|
|
function raijuAiMigrateSecrets(): bool {
|
|
$raw = raijuAiReadJsonObject(RAIJU_AI_SETTINGS_PATH);
|
|
if (!$raw || raijuAiExtractSecrets($raw) === [] && !isset($raw['apiKey'])) return false;
|
|
$moved = false;
|
|
$done = raijuJsonUpdate(RAIJU_AI_SETTINGS_PATH, static function (array &$data) use (&$moved): bool {
|
|
$secrets = raijuAiExtractSecrets($data);
|
|
if ($secrets === [] && !isset($data['apiKey'])) return false;
|
|
// Keys that are already in the secrets file win; the file may have been written by a newer save.
|
|
$secrets = array_replace_recursive($secrets, raijuAiReadJsonObject(RAIJU_AI_SECRETS_PATH));
|
|
if (!raijuAiWriteSecrets($secrets)) return false;
|
|
$data = raijuAiStripSecrets($data);
|
|
$moved = true;
|
|
return true;
|
|
});
|
|
return $done && $moved;
|
|
}
|
|
|
|
/**
|
|
* Delete one stored key right away. $target: "provider:<provider>", "image:<service>", "brave" or "pexels". Anything else is refused.
|
|
* Returns whether a key existed.
|
|
*/
|
|
function raijuAiClearSecret(string $target): bool {
|
|
raijuAiMigrateSecrets(); // a key an older version left in the settings file must go too
|
|
$secrets = raijuAiReadJsonObject(RAIJU_AI_SECRETS_PATH);
|
|
$existed = false;
|
|
if (str_starts_with($target, 'provider:')) {
|
|
$provider = substr($target, 9);
|
|
if (!in_array($provider, ['openai-compatible', 'openrouter', 'anthropic', 'kie'], true)) throw new InvalidArgumentException('Unknown provider.');
|
|
$existed = trim((string)($secrets['providerCredentials'][$provider]['apiKey'] ?? '')) !== '';
|
|
unset($secrets['providerCredentials'][$provider]['apiKey']);
|
|
if (empty($secrets['providerCredentials'][$provider])) unset($secrets['providerCredentials'][$provider]);
|
|
if (empty($secrets['providerCredentials'])) unset($secrets['providerCredentials']);
|
|
} elseif (str_starts_with($target, 'image:')) {
|
|
$service = substr($target, 6);
|
|
if (!in_array($service, ['openai-compatible', 'openrouter', 'kie', 'fal', 'comet', 'custom'], true)) throw new InvalidArgumentException('Unknown image service.');
|
|
$existed = trim((string)($secrets['imageServices'][$service]['apiKey'] ?? '')) !== '';
|
|
unset($secrets['imageServices'][$service]);
|
|
if (empty($secrets['imageServices'])) unset($secrets['imageServices']);
|
|
} elseif ($target === 'brave') {
|
|
$existed = trim((string)($secrets['research']['brave']['apiKey'] ?? '')) !== '';
|
|
unset($secrets['research']);
|
|
} elseif ($target === 'pexels') {
|
|
$existed = trim((string)($secrets['stock']['pexels']['apiKey'] ?? '')) !== '';
|
|
unset($secrets['stock']);
|
|
} else {
|
|
throw new InvalidArgumentException('Unknown key.');
|
|
}
|
|
if (!raijuAiWriteSecrets($secrets)) throw new RuntimeException('Could not remove the key.');
|
|
return $existed;
|
|
}
|