feat: ssh auth, protocol management, and cleanup
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
<?php
|
||||
/**
|
||||
* Backup library utilities for importing servers from backup files.
|
||||
*/
|
||||
class BackupLibrary {
|
||||
/**
|
||||
* Discover available backup files.
|
||||
*
|
||||
* @param bool $registerTokens Whether to register tokens in the session
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function listAvailable(bool $registerTokens = false): array {
|
||||
if (!isset($_SESSION['backup_library']) || !is_array($_SESSION['backup_library'])) {
|
||||
$_SESSION['backup_library'] = [];
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['backup_uploads']) || !is_array($_SESSION['backup_uploads'])) {
|
||||
$_SESSION['backup_uploads'] = [];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach (self::getDirectories() as $directory) {
|
||||
$files = glob($directory . DIRECTORY_SEPARATOR . '*.{backup,json}', GLOB_BRACE) ?: [];
|
||||
foreach ($files as $filePath) {
|
||||
if (!is_file($filePath) || !is_readable($filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$parsed = BackupParser::parseMetadata($filePath);
|
||||
} catch (Throwable $e) {
|
||||
// Skip invalid backup file but log for debugging
|
||||
error_log('Backup parse failed for ' . $filePath . ': ' . $e->getMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (empty($parsed['servers'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token = hash('sha256', $filePath);
|
||||
if ($registerTokens || !isset($_SESSION['backup_library'][$token])) {
|
||||
$_SESSION['backup_library'][$token] = $filePath;
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'token' => $token,
|
||||
'file_name' => basename($filePath),
|
||||
'type' => $parsed['type'],
|
||||
'origin' => 'filesystem',
|
||||
'servers' => self::mapServerMetadata($parsed['servers'] ?? []),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($_SESSION['backup_uploads'] as $token => $upload) {
|
||||
$results[] = [
|
||||
'token' => $token,
|
||||
'file_name' => $upload['file_name'],
|
||||
'type' => $upload['type'],
|
||||
'origin' => 'upload',
|
||||
'servers' => self::mapServerMetadata($upload['data']['servers'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
usort($results, function ($a, $b) {
|
||||
return strcmp($a['file_name'], $b['file_name']);
|
||||
});
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load full server data from backup using the session token and server index.
|
||||
*
|
||||
* @param string $token Backup token
|
||||
* @param int $serverIndex Index of the server inside backup file
|
||||
* @return array<string, mixed>
|
||||
* @throws Exception When token or server not found
|
||||
*/
|
||||
public static function loadServer(string $token, int $serverIndex): array {
|
||||
$path = $_SESSION['backup_library'][$token] ?? null;
|
||||
if ($path) {
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
throw new Exception('Selected backup is not available');
|
||||
}
|
||||
|
||||
$parsed = BackupParser::parse($path);
|
||||
if (!isset($parsed['servers'][$serverIndex])) {
|
||||
throw new Exception('Requested server not found in backup');
|
||||
}
|
||||
|
||||
$server = $parsed['servers'][$serverIndex];
|
||||
$server['source_file'] = $path;
|
||||
$server['type'] = $parsed['type'];
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
$upload = $_SESSION['backup_uploads'][$token] ?? null;
|
||||
if ($upload) {
|
||||
$parsed = $upload['data'];
|
||||
if (!isset($parsed['servers'][$serverIndex])) {
|
||||
throw new Exception('Requested server not found in uploaded backup');
|
||||
}
|
||||
|
||||
$server = $parsed['servers'][$serverIndex];
|
||||
$server['source_file'] = $upload['path'];
|
||||
$server['type'] = $parsed['type'];
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
throw new Exception('Selected backup is not available');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of directories that may contain backup files.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function getDirectories(): array {
|
||||
$directories = [];
|
||||
|
||||
$default = realpath(__DIR__ . '/../backups');
|
||||
if ($default) {
|
||||
$directories[] = $default;
|
||||
}
|
||||
|
||||
$envDirs = Config::get('BACKUP_LIBRARY_DIRS');
|
||||
if (!empty($envDirs)) {
|
||||
foreach (preg_split('/[;,]+/', $envDirs) as $rawDir) {
|
||||
$normalized = trim($rawDir);
|
||||
if ($normalized === '') {
|
||||
continue;
|
||||
}
|
||||
if (is_dir($normalized)) {
|
||||
$real = realpath($normalized);
|
||||
if ($real) {
|
||||
$directories[] = $real;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$home = getenv('HOME');
|
||||
if ($home) {
|
||||
$candidate = realpath($home . DIRECTORY_SEPARATOR . 'Downloads' . DIRECTORY_SEPARATOR . 'infosave');
|
||||
if ($candidate) {
|
||||
$directories[] = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
$directories = array_values(array_unique($directories));
|
||||
|
||||
return $directories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register uploaded backup file and return metadata for UI.
|
||||
*/
|
||||
public static function registerUploaded(string $fileName, string $storedPath, array $parsed): array {
|
||||
if (!isset($_SESSION['backup_uploads']) || !is_array($_SESSION['backup_uploads'])) {
|
||||
$_SESSION['backup_uploads'] = [];
|
||||
}
|
||||
|
||||
$token = 'upload_' . bin2hex(random_bytes(16));
|
||||
|
||||
$_SESSION['backup_uploads'][$token] = [
|
||||
'file_name' => $fileName,
|
||||
'path' => $storedPath,
|
||||
'type' => $parsed['type'],
|
||||
'data' => $parsed,
|
||||
];
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'file_name' => $fileName,
|
||||
'type' => $parsed['type'],
|
||||
'origin' => 'upload',
|
||||
'servers' => self::mapServerMetadata($parsed['servers'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether provided token belongs to uploaded backup.
|
||||
*/
|
||||
public static function isUploadToken(string $token): bool {
|
||||
return isset($_SESSION['backup_uploads'][$token]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve stored upload metadata for a token.
|
||||
*/
|
||||
public static function getUploadRecord(string $token): ?array {
|
||||
if (!isset($_SESSION['backup_uploads'][$token])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $_SESSION['backup_uploads'][$token];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lightweight server metadata for an uploaded backup token.
|
||||
*/
|
||||
public static function getUploadServers(string $token): array {
|
||||
$upload = self::getUploadRecord($token);
|
||||
if (!$upload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::mapServerMetadata($upload['data']['servers'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget uploaded backup token and remove temporary file.
|
||||
*/
|
||||
public static function forgetUpload(string $token): void {
|
||||
$upload = $_SESSION['backup_uploads'][$token] ?? null;
|
||||
if (!$upload) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($_SESSION['backup_uploads'][$token]);
|
||||
|
||||
$path = $upload['path'] ?? null;
|
||||
if ($path && is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map server metadata for front-end lists.
|
||||
*/
|
||||
public static function mapServerMetadata($servers): array {
|
||||
if (!is_array($servers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(function ($server, $index) {
|
||||
return [
|
||||
'index' => $index,
|
||||
'label' => $server['label'] ?? ('Server #' . ($index + 1)),
|
||||
'host' => $server['host'] ?? null,
|
||||
'vpn_port' => $server['vpn_port'] ?? null,
|
||||
'client_count' => isset($server['clients']) && is_array($server['clients'])
|
||||
? count($server['clients'])
|
||||
: 0
|
||||
];
|
||||
}, $servers, array_keys($servers));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse backup files and normalize into a single representation.
|
||||
*/
|
||||
class BackupParser {
|
||||
/**
|
||||
* Parse backup file metadata without storing heavy payloads.
|
||||
*
|
||||
* @param string $path
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function parseMetadata(string $path): array {
|
||||
$parsed = self::parse($path);
|
||||
|
||||
// Strip client details to keep metadata light
|
||||
$parsed['servers'] = array_map(function ($server) {
|
||||
$server['clients'] = $server['clients'] ?? [];
|
||||
return $server;
|
||||
}, $parsed['servers']);
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse backup file fully.
|
||||
*
|
||||
* @param string $path
|
||||
* @return array<string, mixed>
|
||||
* @throws Exception On parse errors
|
||||
*/
|
||||
public static function parse(string $path): array {
|
||||
$contents = file_get_contents($path);
|
||||
if ($contents === false) {
|
||||
throw new Exception('Unable to read backup file');
|
||||
}
|
||||
|
||||
$decoded = json_decode($contents, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new Exception('Backup file is not valid JSON');
|
||||
}
|
||||
|
||||
if (isset($decoded['server']) && isset($decoded['clients'])) {
|
||||
return self::parsePanelBackup($decoded);
|
||||
}
|
||||
|
||||
if (isset($decoded['Servers/serversList'])) {
|
||||
return self::parseAmneziaBackup($decoded);
|
||||
}
|
||||
|
||||
throw new Exception('Unsupported backup format');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse backup produced by the Amnezia mobile/desktop application (.backup files).
|
||||
*/
|
||||
private static function parseAmneziaBackup(array $decoded): array {
|
||||
$serversRaw = json_decode($decoded['Servers/serversList'] ?? '[]', true);
|
||||
if (!is_array($serversRaw)) {
|
||||
throw new Exception('Invalid Amnezia backup payload');
|
||||
}
|
||||
|
||||
$servers = [];
|
||||
foreach ($serversRaw as $serverIndex => $serverEntry) {
|
||||
$containers = $serverEntry['containers'] ?? [];
|
||||
foreach ($containers as $container) {
|
||||
if (($container['container'] ?? '') !== 'amnezia-awg') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$awg = $container['awg'] ?? [];
|
||||
if (empty($awg)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$host = $serverEntry['hostName'] ?? ($awg['hostName'] ?? null);
|
||||
if (!$host) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$awgParams = [];
|
||||
foreach (['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'] as $key) {
|
||||
if (isset($awg[$key])) {
|
||||
$awgParams[$key] = is_numeric($awg[$key]) ? (int)$awg[$key] : $awg[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$vpnPort = isset($awg['port']) ? (int)$awg['port'] : null;
|
||||
$sshPort = isset($serverEntry['port']) ? (int)$serverEntry['port'] : 22;
|
||||
$sshUser = $serverEntry['userName'] ?? 'root';
|
||||
$sshPass = $serverEntry['password'] ?? '';
|
||||
if ($sshPass === '') {
|
||||
// Skip records without SSH credentials; these are likely client snapshots.
|
||||
continue;
|
||||
}
|
||||
$name = trim($serverEntry['description'] ?? '') ?: $host;
|
||||
|
||||
$subnet = $container['awg']['subnet_address'] ?? null;
|
||||
$clients = [];
|
||||
|
||||
if (!empty($awg['last_config'])) {
|
||||
$lastConfig = json_decode($awg['last_config'], true);
|
||||
if (is_array($lastConfig)) {
|
||||
$clientIp = $lastConfig['client_ip'] ?? null;
|
||||
if (!$subnet && $clientIp) {
|
||||
$subnet = self::inferSubnet($clientIp);
|
||||
}
|
||||
|
||||
$clients[] = [
|
||||
'name' => $lastConfig['client_ip'] ?? ($lastConfig['clientId'] ?? $host . '_client'),
|
||||
'client_ip' => $clientIp,
|
||||
'public_key' => $lastConfig['client_pub_key'] ?? '',
|
||||
'private_key' => $lastConfig['client_priv_key'] ?? '',
|
||||
'preshared_key' => $lastConfig['psk_key'] ?? ($awg['psk_key'] ?? ''),
|
||||
'config' => $lastConfig['config'] ?? '',
|
||||
'status' => 'active',
|
||||
'expires_at' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$subnet) {
|
||||
$subnet = '10.8.1.0/24';
|
||||
} elseif (!str_contains($subnet, '/')) {
|
||||
$subnet .= '/24';
|
||||
}
|
||||
|
||||
$servers[] = [
|
||||
'label' => $name . ' (' . $host . ')',
|
||||
'name' => $name,
|
||||
'host' => $host,
|
||||
'ssh_port' => $sshPort,
|
||||
'ssh_username' => $sshUser ?: 'root',
|
||||
'ssh_password' => $sshPass,
|
||||
'vpn_port' => $vpnPort,
|
||||
'container_name' => $container['container'] ?? 'amnezia-awg',
|
||||
'vpn_subnet' => $subnet,
|
||||
'server_public_key' => $awg['server_pub_key'] ?? null,
|
||||
'preshared_key' => $awg['psk_key'] ?? null,
|
||||
'awg_params' => $awgParams,
|
||||
'clients' => $clients,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'amnezia_app',
|
||||
'servers' => $servers,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse backup generated by this panel (backups/backup_*.json).
|
||||
*/
|
||||
private static function parsePanelBackup(array $decoded): array {
|
||||
$server = $decoded['server'];
|
||||
$awgParams = $server['awg_params'] ?? [];
|
||||
if (is_string($awgParams)) {
|
||||
$decodedParams = json_decode($awgParams, true);
|
||||
if (is_array($decodedParams)) {
|
||||
$awgParams = $decodedParams;
|
||||
}
|
||||
}
|
||||
|
||||
$vpnPort = isset($server['vpn_port']) ? (int)$server['vpn_port'] : null;
|
||||
$sshPort = isset($server['port']) ? (int)$server['port'] : 22;
|
||||
$sshUser = $server['username'] ?? 'root';
|
||||
$sshPass = $server['password'] ?? '';
|
||||
$host = $server['host']
|
||||
?? $server['host_name']
|
||||
?? $server['host_ip']
|
||||
?? null;
|
||||
|
||||
if (!$host) {
|
||||
throw new Exception('Panel backup is missing server host/SSH details. Create the server manually and import its clients via the panel importer.');
|
||||
}
|
||||
|
||||
$clients = [];
|
||||
foreach ($decoded['clients'] as $client) {
|
||||
$clients[] = [
|
||||
'name' => $client['name'] ?? ($client['client_ip'] ?? 'client'),
|
||||
'client_ip' => $client['client_ip'] ?? null,
|
||||
'public_key' => $client['public_key'] ?? '',
|
||||
'private_key' => $client['private_key'] ?? '',
|
||||
'preshared_key' => $client['preshared_key'] ?? ($server['preshared_key'] ?? ''),
|
||||
'config' => $client['config'] ?? '',
|
||||
'status' => $client['status'] ?? 'active',
|
||||
'expires_at' => $client['expires_at'] ?? null,
|
||||
'created_at' => $client['created_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'panel_backup',
|
||||
'servers' => [
|
||||
[
|
||||
'label' => ($server['name'] ?? 'Server') . ' (' . $host . ')',
|
||||
'name' => $server['name'] ?? 'Server',
|
||||
'host' => $host,
|
||||
'ssh_port' => $sshPort,
|
||||
'ssh_username' => $sshUser,
|
||||
'ssh_password' => $sshPass,
|
||||
'vpn_port' => $vpnPort,
|
||||
'container_name' => $server['container_name'] ?? 'amnezia-awg',
|
||||
'vpn_subnet' => $server['vpn_subnet'] ?? '10.8.1.0/24',
|
||||
'server_public_key' => $server['server_public_key'] ?? null,
|
||||
'preshared_key' => $server['preshared_key'] ?? null,
|
||||
'awg_params' => $awgParams,
|
||||
'clients' => $clients,
|
||||
]
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer /24 subnet from client IP.
|
||||
*/
|
||||
private static function inferSubnet(string $ip): string {
|
||||
$parts = explode('.', $ip);
|
||||
if (count($parts) === 4) {
|
||||
return $parts[0] . '.' . $parts[1] . '.' . $parts[2] . '.0/24';
|
||||
}
|
||||
return '10.8.1.0/24';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
class Logger {
|
||||
private const DEFAULT_LOGS_DIR = __DIR__ . '/../logs';
|
||||
|
||||
private static function ensureDir(string $dir): void {
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0777, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static function getLogsDir(): string {
|
||||
// Fallback to project logs directory next to inc/
|
||||
$dir = self::DEFAULT_LOGS_DIR;
|
||||
self::ensureDir($dir);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public static function appendInstall(int $serverId, string $message): void {
|
||||
$dir = self::getLogsDir();
|
||||
$file = $dir . '/install_server_' . $serverId . '.log';
|
||||
$line = '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
|
||||
@file_put_contents($file, $line, FILE_APPEND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
class OpenRouterService {
|
||||
|
||||
private $apiKey;
|
||||
private $apiUrl = 'https://openrouter.ai/api/v1';
|
||||
private $timeout = 60; // 60 seconds timeout for AI generation
|
||||
|
||||
public function __construct() {
|
||||
$this->apiKey = $_ENV['OPENROUTER_API_KEY'] ?? null;
|
||||
if (!$this->apiKey) {
|
||||
throw new Exception('OpenRouter API key not configured');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate installation script using OpenRouter API
|
||||
*/
|
||||
public function generateScript(string $prompt, string $model = 'openai/gpt-3.5-turbo'): array {
|
||||
try {
|
||||
$messages = [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => 'You are a helpful assistant that creates bash installation scripts for VPN protocols. Always respond with valid JSON containing the script, suggestions, ubuntu compatibility, and estimated installation time.'
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $prompt
|
||||
]
|
||||
];
|
||||
|
||||
$response = $this->makeAPICall('/chat/completions', [
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'temperature' => 0.3, // Lower temperature for more consistent results
|
||||
'max_tokens' => 4000, // Sufficient for detailed scripts
|
||||
'response_format' => ['type' => 'json_object']
|
||||
]);
|
||||
|
||||
if (!isset($response['choices'][0]['message']['content'])) {
|
||||
throw new Exception('Invalid response from OpenRouter API');
|
||||
}
|
||||
|
||||
$content = $response['choices'][0]['message']['content'];
|
||||
$parsed = json_decode($content, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
// If JSON parsing fails, try to extract script from plain text
|
||||
return $this->parsePlainTextResponse($content);
|
||||
}
|
||||
|
||||
return $this->validateAndEnhanceResponse($parsed);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error in OpenRouterService::generateScript: " . $e->getMessage());
|
||||
throw new Exception('Failed to generate script: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available AI models from OpenRouter
|
||||
*/
|
||||
public function getAvailableModels(): array {
|
||||
try {
|
||||
$response = $this->makeAPICall('/models', [], 'GET');
|
||||
|
||||
if (!isset($response['data'])) {
|
||||
throw new Exception('Invalid response from OpenRouter API');
|
||||
}
|
||||
|
||||
// Filter models suitable for code generation
|
||||
$codeModels = array_filter($response['data'], function($model) {
|
||||
$codeModelIds = [
|
||||
'openai/gpt-3.5-turbo',
|
||||
'openai/gpt-4',
|
||||
'openai/gpt-4-turbo',
|
||||
'anthropic/claude-3-haiku',
|
||||
'anthropic/claude-3-sonnet',
|
||||
'anthropic/claude-3-opus',
|
||||
'google/gemini-pro',
|
||||
'meta-llama/llama-2-70b-chat',
|
||||
'meta-llama/llama-3-70b-instruct'
|
||||
];
|
||||
|
||||
return in_array($model['id'], $codeModelIds) && $model['top_provider'] === true;
|
||||
});
|
||||
|
||||
return array_values(array_map(function($model) {
|
||||
return [
|
||||
'id' => $model['id'],
|
||||
'name' => $model['name'] ?? $model['id'],
|
||||
'description' => $model['description'] ?? '',
|
||||
'pricing' => $model['pricing'] ?? null
|
||||
];
|
||||
}, $codeModels));
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error in OpenRouterService::getAvailableModels: " . $e->getMessage());
|
||||
// Return default models if API call fails
|
||||
return $this->getDefaultModels();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make API call to OpenRouter
|
||||
*/
|
||||
private function makeAPICall(string $endpoint, array $data = [], string $method = 'POST'): array {
|
||||
$ch = curl_init();
|
||||
|
||||
$url = $this->apiUrl . $endpoint;
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $this->apiKey,
|
||||
'Content-Type: application/json',
|
||||
'HTTP-Referer: ' . ($_ENV['APP_URL'] ?? 'https://localhost'),
|
||||
'X-Title: Amnezia VPN Panel'
|
||||
],
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2
|
||||
]);
|
||||
|
||||
if ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
}
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
throw new Exception('CURL error: ' . $error);
|
||||
}
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
$errorData = json_decode($response, true);
|
||||
$errorMessage = $errorData['error']['message'] ?? "HTTP $httpCode error";
|
||||
throw new Exception($errorMessage);
|
||||
}
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new Exception('Invalid JSON response from OpenRouter API');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse plain text response when JSON parsing fails
|
||||
*/
|
||||
private function parsePlainTextResponse(string $content): array {
|
||||
// Try to extract bash script from plain text
|
||||
if (preg_match('/```bash\n(.*?)\n```/s', $content, $matches)) {
|
||||
$script = trim($matches[1]);
|
||||
} elseif (preg_match('/```(.*?)```/s', $content, $matches)) {
|
||||
$script = trim($matches[1]);
|
||||
} else {
|
||||
// If no code blocks found, treat the entire content as script
|
||||
$script = trim($content);
|
||||
}
|
||||
|
||||
// Add bash shebang if not present
|
||||
if (!str_starts_with($script, '#!')) {
|
||||
$script = "#!/bin/bash\n\n" . $script;
|
||||
}
|
||||
|
||||
return [
|
||||
'script' => $script,
|
||||
'suggestions' => [
|
||||
'Check the script for syntax errors',
|
||||
'Test the script in a safe environment',
|
||||
'Review security implications'
|
||||
],
|
||||
'ubuntu_compatible' => true,
|
||||
'estimated_time' => '5 minutes'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and enhance AI response
|
||||
*/
|
||||
private function validateAndEnhanceResponse(array $response): array {
|
||||
$defaults = [
|
||||
'script' => '#!/bin/bash\n# Default installation script\necho "Installation script placeholder"',
|
||||
'suggestions' => [],
|
||||
'ubuntu_compatible' => true,
|
||||
'estimated_time' => '5 minutes'
|
||||
];
|
||||
|
||||
// Ensure all required fields are present
|
||||
foreach ($defaults as $key => $defaultValue) {
|
||||
if (!isset($response[$key])) {
|
||||
$response[$key] = $defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate script format
|
||||
if (!str_starts_with(trim($response['script']), '#!')) {
|
||||
$response['script'] = "#!/bin/bash\n\n" . $response['script'];
|
||||
}
|
||||
|
||||
// Ensure suggestions is an array
|
||||
if (!is_array($response['suggestions'])) {
|
||||
$response['suggestions'] = [];
|
||||
}
|
||||
|
||||
// Add default suggestions if none provided
|
||||
if (empty($response['suggestions'])) {
|
||||
$response['suggestions'] = [
|
||||
'Review the generated script for security implications',
|
||||
'Test the script in a development environment first',
|
||||
'Ensure all dependencies are available on your system',
|
||||
'Backup your system before running the script'
|
||||
];
|
||||
}
|
||||
|
||||
// Validate ubuntu_compatible is boolean
|
||||
if (!is_bool($response['ubuntu_compatible'])) {
|
||||
$response['ubuntu_compatible'] = true;
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default models when API is unavailable
|
||||
*/
|
||||
private function getDefaultModels(): array {
|
||||
return [
|
||||
[
|
||||
'id' => 'openai/gpt-3.5-turbo',
|
||||
'name' => 'GPT-3.5 Turbo',
|
||||
'description' => 'Fast and cost-effective model for general purpose tasks',
|
||||
'pricing' => ['prompt' => '0.001', 'completion' => '0.002']
|
||||
],
|
||||
[
|
||||
'id' => 'openai/gpt-4',
|
||||
'name' => 'GPT-4',
|
||||
'description' => 'Most capable model for complex tasks',
|
||||
'pricing' => ['prompt' => '0.03', 'completion' => '0.06']
|
||||
],
|
||||
[
|
||||
'id' => 'anthropic/claude-3-haiku',
|
||||
'name' => 'Claude 3 Haiku',
|
||||
'description' => 'Fast and cost-effective model from Anthropic',
|
||||
'pricing' => ['prompt' => '0.00025', 'completion' => '0.00125']
|
||||
],
|
||||
[
|
||||
'id' => 'anthropic/claude-3-sonnet',
|
||||
'name' => 'Claude 3 Sonnet',
|
||||
'description' => 'Balanced performance and cost from Anthropic',
|
||||
'pricing' => ['prompt' => '0.003', 'completion' => '0.015']
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function testModelAvailability(string $modelId): array {
|
||||
if (!$this->apiKey) {
|
||||
return [
|
||||
'success' => false,
|
||||
'http_code' => 401,
|
||||
'message' => 'OpenRouter API key not configured'
|
||||
];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'model' => $modelId,
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => 'Reply with: OK']
|
||||
],
|
||||
'max_tokens' => 5,
|
||||
'temperature' => 0
|
||||
];
|
||||
|
||||
$ch = curl_init($this->apiUrl . '/chat/completions');
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $this->apiKey,
|
||||
'HTTP-Referer: https://amnez.ia',
|
||||
'X-Title: Amnezia VPN Panel'
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError) {
|
||||
return [
|
||||
'success' => false,
|
||||
'http_code' => null,
|
||||
'message' => 'Network error: ' . $curlError
|
||||
];
|
||||
}
|
||||
|
||||
$json = json_decode($response, true);
|
||||
$ok = $httpCode === 200 && isset($json['choices'][0]['message']['content']);
|
||||
return [
|
||||
'success' => $ok,
|
||||
'http_code' => $httpCode,
|
||||
'message' => $ok ? 'Model is available' : ($json['error']['message'] ?? 'Model test failed')
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
|
||||
class ProtocolService
|
||||
{
|
||||
|
||||
/**
|
||||
* Get all protocols with additional metadata
|
||||
*/
|
||||
public static function getAllProtocolsWithStats(): array
|
||||
{
|
||||
try {
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->query('
|
||||
SELECT p.*,
|
||||
COUNT(DISTINCT sp.server_id) as server_count,
|
||||
COUNT(DISTINCT pt.id) as template_count,
|
||||
COUNT(DISTINCT pv.id) as variable_count,
|
||||
COUNT(DISTINCT ag.id) as ai_generation_count,
|
||||
MAX(ag.created_at) as last_ai_generation
|
||||
FROM protocols p
|
||||
LEFT JOIN server_protocols sp ON p.id = sp.protocol_id
|
||||
LEFT JOIN protocol_templates pt ON p.id = pt.protocol_id
|
||||
LEFT JOIN protocol_variables pv ON p.id = pv.protocol_id
|
||||
LEFT JOIN ai_generations ag ON p.id = ag.protocol_id
|
||||
GROUP BY p.id
|
||||
ORDER BY p.name ASC
|
||||
');
|
||||
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error in ProtocolService::getAllProtocolsWithStats: " . $e->getMessage());
|
||||
throw new Exception('Failed to get protocols with stats');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protocol with all related data (templates, variables, AI history)
|
||||
*/
|
||||
public static function getProtocolWithDetails(int $protocolId): array
|
||||
{
|
||||
try {
|
||||
$pdo = DB::conn();
|
||||
|
||||
// Get protocol
|
||||
$stmt = $pdo->prepare('SELECT * FROM protocols WHERE id = ?');
|
||||
$stmt->execute([$protocolId]);
|
||||
$protocol = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$protocol) {
|
||||
throw new Exception('Protocol not found');
|
||||
}
|
||||
|
||||
// Get templates
|
||||
$stmt = $pdo->prepare('SELECT * FROM protocol_templates WHERE protocol_id = ? ORDER BY is_default DESC, template_name ASC');
|
||||
$stmt->execute([$protocolId]);
|
||||
$protocol['templates'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Get variables
|
||||
$stmt = $pdo->prepare('SELECT * FROM protocol_variables WHERE protocol_id = ? ORDER BY variable_name ASC');
|
||||
$stmt->execute([$protocolId]);
|
||||
$protocol['variables'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Get AI generation history (last 10)
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT ag.*, p.name as protocol_name
|
||||
FROM ai_generations ag
|
||||
LEFT JOIN protocols p ON ag.protocol_id = p.id
|
||||
WHERE ag.protocol_id = ?
|
||||
ORDER BY ag.created_at DESC
|
||||
LIMIT 10
|
||||
');
|
||||
$stmt->execute([$protocolId]);
|
||||
$protocol['ai_history'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Get server usage
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT sp.*, vs.name as server_name, vs.host as server_host
|
||||
FROM server_protocols sp
|
||||
JOIN vpn_servers vs ON sp.server_id = vs.id
|
||||
WHERE sp.protocol_id = ?
|
||||
ORDER BY sp.applied_at DESC
|
||||
');
|
||||
$stmt->execute([$protocolId]);
|
||||
$protocol['server_usage'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return $protocol;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error in ProtocolService::getProtocolWithDetails: " . $e->getMessage());
|
||||
throw new Exception('Failed to get protocol details');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate protocol data before saving
|
||||
*/
|
||||
public static function validateProtocolData(array $data): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Validate name
|
||||
if (empty($data['name'])) {
|
||||
$errors[] = 'Protocol name is required';
|
||||
} elseif (strlen($data['name']) > 255) {
|
||||
$errors[] = 'Protocol name must be less than 255 characters';
|
||||
}
|
||||
|
||||
// Validate slug
|
||||
if (empty($data['slug'])) {
|
||||
$errors[] = 'Protocol slug is required';
|
||||
} elseif (!preg_match('/^[a-z0-9_-]+$/i', $data['slug'])) {
|
||||
$errors[] = 'Slug may contain only letters, numbers, dashes, and underscores';
|
||||
} elseif (strlen($data['slug']) > 100) {
|
||||
$errors[] = 'Protocol slug must be less than 100 characters';
|
||||
}
|
||||
|
||||
// Validate description length
|
||||
if (isset($data['description']) && strlen($data['description']) > 65535) {
|
||||
$errors[] = 'Description is too long';
|
||||
}
|
||||
|
||||
// Validate install script
|
||||
if (isset($data['install_script']) && strlen($data['install_script']) > 16777215) { // MEDIUMTEXT limit
|
||||
$errors[] = 'Installation script is too long';
|
||||
}
|
||||
|
||||
// Validate output template
|
||||
if (isset($data['output_template']) && strlen($data['output_template']) > 16777215) { // MEDIUMTEXT limit
|
||||
$errors[] = 'Output template is too long';
|
||||
}
|
||||
|
||||
// Validate ubuntu_compatible
|
||||
if (isset($data['ubuntu_compatible']) && !is_bool($data['ubuntu_compatible']) && !in_array($data['ubuntu_compatible'], [0, 1, '0', '1'])) {
|
||||
$errors[] = 'Ubuntu compatible must be a boolean value';
|
||||
}
|
||||
|
||||
// Validate is_active
|
||||
if (isset($data['is_active']) && !is_bool($data['is_active']) && !in_array($data['is_active'], [0, 1, '0', '1'])) {
|
||||
$errors[] = 'Active status must be a boolean value';
|
||||
}
|
||||
|
||||
// Validate QR code template
|
||||
if (isset($data['qr_code_template']) && strlen($data['qr_code_template']) > 16777215) {
|
||||
$errors[] = 'QR code template is too long';
|
||||
}
|
||||
|
||||
// Validate QR code format
|
||||
if (isset($data['qr_code_format']) && !in_array($data['qr_code_format'], ['raw', 'amnezia_compressed'])) {
|
||||
$errors[] = 'Invalid QR code format';
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if slug is unique
|
||||
*/
|
||||
public static function isSlugUnique(string $slug, ?int $excludeId = null): bool
|
||||
{
|
||||
try {
|
||||
$pdo = DB::conn();
|
||||
|
||||
if ($excludeId) {
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM protocols WHERE slug = ? AND id != ?');
|
||||
$stmt->execute([$slug, $excludeId]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM protocols WHERE slug = ?');
|
||||
$stmt->execute([$slug]);
|
||||
}
|
||||
|
||||
return (int) $stmt->fetchColumn() === 0;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error in ProtocolService::isSlugUnique: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if protocol can be deleted
|
||||
*/
|
||||
public static function canDeleteProtocol(int $protocolId): array
|
||||
{
|
||||
try {
|
||||
$pdo = DB::conn();
|
||||
|
||||
// Check if protocol is used by any servers
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM server_protocols WHERE protocol_id = ?');
|
||||
$stmt->execute([$protocolId]);
|
||||
$serverCount = (int) $stmt->fetchColumn();
|
||||
|
||||
$canDelete = $serverCount === 0;
|
||||
$reason = '';
|
||||
|
||||
if (!$canDelete) {
|
||||
$reason = "Protocol is currently used by $serverCount server(s)";
|
||||
}
|
||||
|
||||
return [
|
||||
'can_delete' => $canDelete,
|
||||
'reason' => $reason,
|
||||
'server_count' => $serverCount
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error in ProtocolService::canDeleteProtocol: " . $e->getMessage());
|
||||
return [
|
||||
'can_delete' => false,
|
||||
'reason' => 'Database error occurred',
|
||||
'server_count' => 0
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate protocol template with variables
|
||||
*/
|
||||
public static function generateProtocolOutput(array $protocol, array $variables): string
|
||||
{
|
||||
try {
|
||||
$template = $protocol['output_template'] ?? '';
|
||||
|
||||
if (empty($template)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach ($variables as $key => $value) {
|
||||
$template = str_replace('{{' . $key . '}}', $value ?? '', $template);
|
||||
}
|
||||
$template = preg_replace('/(\w+:\/\/[^\/:]+):(?=\/|\?|$)/', '$1', $template);
|
||||
$template = preg_replace('/(@[^\/:]+):(?=\/|\?|$)/', '$1', $template);
|
||||
$template = preg_replace('/(\w+:\/\/)@(?=[^\/]{1})/', '$1', $template);
|
||||
$template = preg_replace('/\{\{[^}]+\}\}/', '', $template);
|
||||
|
||||
// Check for unreplaced variables
|
||||
if (preg_match('/\{\{([^}]+)\}\}/', $template, $matches)) {
|
||||
error_log("Unreplaced variables in protocol template: " . implode(', ', $matches));
|
||||
}
|
||||
|
||||
return $template;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error in ProtocolService::generateProtocolOutput: " . $e->getMessage());
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code payload from template
|
||||
*/
|
||||
public static function generateQrCodePayload(array $protocol, array $variables): string
|
||||
{
|
||||
try {
|
||||
$template = $protocol['qr_code_template'] ?? '';
|
||||
$format = $protocol['qr_code_format'] ?? 'amnezia_compressed';
|
||||
|
||||
if (empty($template)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Render template using the same logic as output template
|
||||
// We temporarily wrap it to use the existing method
|
||||
$rendered = self::generateProtocolOutput(['output_template' => $template], $variables);
|
||||
|
||||
if ($format === 'amnezia_compressed') {
|
||||
require_once __DIR__ . '/QrUtil.php';
|
||||
return QrUtil::encodeOldPayloadFromJson($rendered);
|
||||
}
|
||||
|
||||
// For 'raw' and 'text' formats, return rendered template directly
|
||||
return $rendered;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error in ProtocolService::generateQrCodePayload: " . $e->getMessage());
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protocol statistics for dashboard
|
||||
*/
|
||||
public static function getProtocolStatistics(): array
|
||||
{
|
||||
try {
|
||||
$pdo = DB::conn();
|
||||
|
||||
// Total protocols
|
||||
$stmt = $pdo->query('SELECT COUNT(*) FROM protocols');
|
||||
$totalProtocols = (int) $stmt->fetchColumn();
|
||||
|
||||
// Active protocols
|
||||
$stmt = $pdo->query('SELECT COUNT(*) FROM protocols WHERE is_active = 1');
|
||||
$activeProtocols = (int) $stmt->fetchColumn();
|
||||
|
||||
// Ubuntu compatible protocols
|
||||
$stmt = $pdo->query('SELECT COUNT(*) FROM protocols WHERE ubuntu_compatible = 1');
|
||||
$ubuntuCompatibleProtocols = (int) $stmt->fetchColumn();
|
||||
|
||||
// Protocols with AI generations
|
||||
$stmt = $pdo->query('
|
||||
SELECT COUNT(DISTINCT protocol_id)
|
||||
FROM ai_generations
|
||||
WHERE protocol_id IS NOT NULL
|
||||
');
|
||||
$protocolsWithAI = (int) $stmt->fetchColumn();
|
||||
|
||||
// Recent AI generations
|
||||
$stmt = $pdo->query('
|
||||
SELECT COUNT(*)
|
||||
FROM ai_generations
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
');
|
||||
$recentAIGenerations = (int) $stmt->fetchColumn();
|
||||
|
||||
// Server usage by protocol
|
||||
$stmt = $pdo->query('
|
||||
SELECT p.name, COUNT(sp.server_id) as server_count
|
||||
FROM protocols p
|
||||
LEFT JOIN server_protocols sp ON p.id = sp.protocol_id
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY server_count DESC
|
||||
LIMIT 10
|
||||
');
|
||||
$serverUsageByProtocol = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return [
|
||||
'total_protocols' => $totalProtocols,
|
||||
'active_protocols' => $activeProtocols,
|
||||
'ubuntu_compatible_protocols' => $ubuntuCompatibleProtocols,
|
||||
'protocols_with_ai' => $protocolsWithAI,
|
||||
'recent_ai_generations' => $recentAIGenerations,
|
||||
'server_usage_by_protocol' => $serverUsageByProtocol,
|
||||
'ai_usage_percentage' => $totalProtocols > 0 ? round(($protocolsWithAI / $totalProtocols) * 100, 2) : 0
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error in ProtocolService::getProtocolStatistics: " . $e->getMessage());
|
||||
return [
|
||||
'total_protocols' => 0,
|
||||
'active_protocols' => 0,
|
||||
'ubuntu_compatible_protocols' => 0,
|
||||
'protocols_with_ai' => 0,
|
||||
'recent_ai_generations' => 0,
|
||||
'server_usage_by_protocol' => [],
|
||||
'ai_usage_percentage' => 0
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AI generation statistics
|
||||
*/
|
||||
public static function getAIGenerationStatistics(): array
|
||||
{
|
||||
try {
|
||||
$pdo = DB::conn();
|
||||
|
||||
// Total AI generations
|
||||
$stmt = $pdo->query('SELECT COUNT(*) FROM ai_generations');
|
||||
$totalGenerations = (int) $stmt->fetchColumn();
|
||||
|
||||
// AI generations this month
|
||||
$stmt = $pdo->query('
|
||||
SELECT COUNT(*)
|
||||
FROM ai_generations
|
||||
WHERE MONTH(created_at) = MONTH(NOW()) AND YEAR(created_at) = YEAR(NOW())
|
||||
');
|
||||
$thisMonthGenerations = (int) $stmt->fetchColumn();
|
||||
|
||||
// AI generations by model
|
||||
$stmt = $pdo->query('
|
||||
SELECT model_used, COUNT(*) as count
|
||||
FROM ai_generations
|
||||
GROUP BY model_used
|
||||
ORDER BY count DESC
|
||||
');
|
||||
$generationsByModel = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Ubuntu compatible generations
|
||||
$stmt = $pdo->query('
|
||||
SELECT COUNT(*)
|
||||
FROM ai_generations
|
||||
WHERE ubuntu_compatible = 1
|
||||
');
|
||||
$ubuntuCompatibleGenerations = (int) $stmt->fetchColumn();
|
||||
|
||||
return [
|
||||
'total_generations' => $totalGenerations,
|
||||
'this_month_generations' => $thisMonthGenerations,
|
||||
'generations_by_model' => $generationsByModel,
|
||||
'ubuntu_compatible_generations' => $ubuntuCompatibleGenerations,
|
||||
'ubuntu_compatible_percentage' => $totalGenerations > 0 ? round(($ubuntuCompatibleGenerations / $totalGenerations) * 100, 2) : 0
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Error in ProtocolService::getAIGenerationStatistics: " . $e->getMessage());
|
||||
return [
|
||||
'total_generations' => 0,
|
||||
'this_month_generations' => 0,
|
||||
'generations_by_model' => [],
|
||||
'ubuntu_compatible_generations' => 0,
|
||||
'ubuntu_compatible_percentage' => 0
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
+292
-48
@@ -7,8 +7,10 @@ use Endroid\QrCode\Label\Label;
|
||||
use Endroid\QrCode\Label\LabelAlignment;
|
||||
use Endroid\QrCode\Encoding\Encoding;
|
||||
|
||||
class QrUtil {
|
||||
public static function pngBase64(string $text, int $size = 300, int $margin = 1, string $label = 'Amnezia QR (old)') : string {
|
||||
class QrUtil
|
||||
{
|
||||
public static function pngBase64(string $text, int $size = 300, int $margin = 1, string $label = 'Amnezia QR (old)'): string
|
||||
{
|
||||
// Try to load Composer autoload if not yet loaded
|
||||
if (!class_exists(QrCode::class)) {
|
||||
$autoload = __DIR__ . '/vendor/autoload.php';
|
||||
@@ -53,11 +55,13 @@ class QrUtil {
|
||||
throw new RuntimeException('QR library not available');
|
||||
}
|
||||
|
||||
private static function urlsafe_b64_encode(string $bytes): string {
|
||||
private static function urlsafe_b64_encode(string $bytes): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
public static function encodeOldPayloadFromJson(string $jsonText): string {
|
||||
public static function encodeOldPayloadFromJson(string $jsonText): string
|
||||
{
|
||||
$json = self::normalizeJson($jsonText);
|
||||
// Old format uses zlib (gzcompress) with header [version, compressed_len, uncompressed_len]
|
||||
$compressed = gzcompress($json, 9);
|
||||
@@ -71,13 +75,15 @@ class QrUtil {
|
||||
return self::urlsafe_b64_encode($header . $compressed);
|
||||
}
|
||||
|
||||
public static function encodeOldPayloadFromConf(string $confText): string {
|
||||
public static function encodeOldPayloadFromConf(string $confText): string
|
||||
{
|
||||
$payload = self::buildOldEnvelopeFromConf($confText);
|
||||
return self::encodeOldPayloadFromJson(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
private static function resolveServerDescription(?string $endpointHost): string {
|
||||
$desc = (string)($endpointHost ?? '');
|
||||
private static function resolveServerDescription(?string $endpointHost): string
|
||||
{
|
||||
$desc = (string) ($endpointHost ?? '');
|
||||
try {
|
||||
$cfgPath = __DIR__ . '/config.php';
|
||||
$dbPath = __DIR__ . '/Database.php';
|
||||
@@ -98,21 +104,32 @@ class QrUtil {
|
||||
return $desc;
|
||||
}
|
||||
|
||||
private static function buildOldEnvelopeFromConf(string $conf): array {
|
||||
$endpointHost = null; $endpointPort = null; $mtu = null; $dns = []; $keepAlive = null;
|
||||
$privKey = null; $pubKeyServer = null; $psk = null; $address = null; $allowedIps = [];
|
||||
public static function parseWireGuardConfig(string $conf): array
|
||||
{
|
||||
$endpointHost = null;
|
||||
$endpointPort = null;
|
||||
$mtu = null;
|
||||
$dns = [];
|
||||
$keepAlive = null;
|
||||
$privKey = null;
|
||||
$pubKeyServer = null;
|
||||
$psk = null;
|
||||
$address = null;
|
||||
$allowedIps = [];
|
||||
foreach (explode("\n", $conf) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') { continue; }
|
||||
if ($line === '' || $line[0] === '#') {
|
||||
continue;
|
||||
}
|
||||
if (stripos($line, 'Endpoint') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
if (preg_match('/^\[?([^\]]+)\]?:([0-9]{2,5})$/', $v, $m)) {
|
||||
$endpointHost = $m[1];
|
||||
$endpointPort = (int)$m[2];
|
||||
$endpointPort = (int) $m[2];
|
||||
}
|
||||
} elseif (stripos($line, 'MTU') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$mtu = (int)$v;
|
||||
$mtu = (int) $v;
|
||||
} elseif (stripos($line, 'DNS') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$dns = array_map('trim', preg_split('/[,\s]+/', $v));
|
||||
@@ -133,13 +150,19 @@ class QrUtil {
|
||||
$allowedIps = array_map('trim', preg_split('/[,\s]+/', $v));
|
||||
} elseif (stripos($line, 'PersistentKeepalive') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$keepAlive = (int)$v;
|
||||
$keepAlive = (int) $v;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$endpointPort) { $endpointPort = 51820; }
|
||||
if (!$mtu) { $mtu = 1280; }
|
||||
if (!$keepAlive) { $keepAlive = 25; }
|
||||
if (!$endpointPort) {
|
||||
$endpointPort = 51820;
|
||||
}
|
||||
if (!$mtu) {
|
||||
$mtu = 1280;
|
||||
}
|
||||
if (!$keepAlive) {
|
||||
$keepAlive = 25;
|
||||
}
|
||||
$dns1 = $dns[0] ?? '1.1.1.1';
|
||||
$dns2 = $dns[1] ?? '1.0.0.1';
|
||||
|
||||
@@ -155,9 +178,15 @@ class QrUtil {
|
||||
|
||||
// Collect obfuscation params from conf if present
|
||||
$params = [
|
||||
'H1' => null, 'H2' => null, 'H3' => null, 'H4' => null,
|
||||
'Jc' => null, 'Jmin' => null, 'Jmax' => null,
|
||||
'S1' => null, 'S2' => null,
|
||||
'H1' => null,
|
||||
'H2' => null,
|
||||
'H3' => null,
|
||||
'H4' => null,
|
||||
'Jc' => null,
|
||||
'Jmin' => null,
|
||||
'Jmax' => null,
|
||||
'S1' => null,
|
||||
'S2' => null,
|
||||
];
|
||||
foreach (explode("\n", $conf) as $line) {
|
||||
$line = trim($line);
|
||||
@@ -171,27 +200,173 @@ class QrUtil {
|
||||
|
||||
// Build last_config JSON object (stringified, pretty-printed)
|
||||
$lastConfigObj = [
|
||||
'H1' => (string)($params['H1'] ?? ''),
|
||||
'H2' => (string)($params['H2'] ?? ''),
|
||||
'H3' => (string)($params['H3'] ?? ''),
|
||||
'H4' => (string)($params['H4'] ?? ''),
|
||||
'Jc' => (string)($params['Jc'] ?? ''),
|
||||
'Jmax' => (string)($params['Jmax'] ?? ''),
|
||||
'Jmin' => (string)($params['Jmin'] ?? ''),
|
||||
'S1' => (string)($params['S1'] ?? ''),
|
||||
'S2' => (string)($params['S2'] ?? ''),
|
||||
'H1' => (string) ($params['H1'] ?? ''),
|
||||
'H2' => (string) ($params['H2'] ?? ''),
|
||||
'H3' => (string) ($params['H3'] ?? ''),
|
||||
'H4' => (string) ($params['H4'] ?? ''),
|
||||
'Jc' => (string) ($params['Jc'] ?? ''),
|
||||
'Jmax' => (string) ($params['Jmax'] ?? ''),
|
||||
'Jmin' => (string) ($params['Jmin'] ?? ''),
|
||||
'S1' => (string) ($params['S1'] ?? ''),
|
||||
'S2' => (string) ($params['S2'] ?? ''),
|
||||
'allowed_ips' => $allowedIps ?: ['0.0.0.0/0', '::/0'],
|
||||
'clientId' => $clientPubKey ?: '',
|
||||
'client_ip' => preg_replace('/\/(\d{1,2})$/', '', (string)($address ?? '')),
|
||||
'client_priv_key' => (string)($privKey ?? ''),
|
||||
'client_ip' => preg_replace('/\/(\d{1,2})$/', '', (string) ($address ?? '')),
|
||||
'client_priv_key' => (string) ($privKey ?? ''),
|
||||
'client_pub_key' => $clientPubKey ?: '',
|
||||
'config' => $conf,
|
||||
'hostName' => (string)($endpointHost ?? ''),
|
||||
'mtu' => (string)$mtu,
|
||||
'persistent_keep_alive' => (string)$keepAlive,
|
||||
'hostName' => (string) ($endpointHost ?? ''),
|
||||
'mtu' => (string) $mtu,
|
||||
'persistent_keep_alive' => (string) $keepAlive,
|
||||
'port' => $endpointPort,
|
||||
'psk_key' => (string)($psk ?? ''),
|
||||
'server_pub_key' => (string)($pubKeyServer ?? ''),
|
||||
'psk_key' => (string) ($psk ?? ''),
|
||||
'server_pub_key' => (string) ($pubKeyServer ?? ''),
|
||||
];
|
||||
|
||||
$serverDesc = self::resolveServerDescription($endpointHost);
|
||||
|
||||
$vars = [
|
||||
'last_config_json' => json_encode($lastConfigObj, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT),
|
||||
'port' => (string) $endpointPort,
|
||||
'description' => $serverDesc,
|
||||
'dns1' => $dns1,
|
||||
'dns2' => $dns2,
|
||||
'hostName' => $endpointHost,
|
||||
'client_pub_key' => $clientPubKey,
|
||||
'client_priv_key' => $privKey,
|
||||
'client_ip' => preg_replace('/\/(\d{1,2})$/', '', (string) ($address ?? '')),
|
||||
'psk_key' => $psk,
|
||||
'server_pub_key' => $pubKeyServer,
|
||||
'mtu' => $mtu,
|
||||
'persistent_keep_alive' => $keepAlive,
|
||||
'config' => $conf,
|
||||
];
|
||||
|
||||
// Add params to vars
|
||||
foreach ($params as $k => $v) {
|
||||
$vars[$k] = (string) ($v ?? '');
|
||||
}
|
||||
|
||||
return $vars;
|
||||
}
|
||||
|
||||
private static function buildOldEnvelopeFromConf(string $conf): array
|
||||
{
|
||||
$endpointHost = null;
|
||||
$endpointPort = null;
|
||||
$mtu = null;
|
||||
$dns = [];
|
||||
$keepAlive = null;
|
||||
$privKey = null;
|
||||
$pubKeyServer = null;
|
||||
$psk = null;
|
||||
$address = null;
|
||||
$allowedIps = [];
|
||||
foreach (explode("\n", $conf) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') {
|
||||
continue;
|
||||
}
|
||||
if (stripos($line, 'Endpoint') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
if (preg_match('/^\[?([^\]]+)\]?:([0-9]{2,5})$/', $v, $m)) {
|
||||
$endpointHost = $m[1];
|
||||
$endpointPort = (int) $m[2];
|
||||
}
|
||||
} elseif (stripos($line, 'MTU') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$mtu = (int) $v;
|
||||
} elseif (stripos($line, 'DNS') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$dns = array_map('trim', preg_split('/[,\s]+/', $v));
|
||||
} elseif (stripos($line, 'PrivateKey') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$privKey = $v;
|
||||
} elseif (stripos($line, 'PublicKey') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$pubKeyServer = $v;
|
||||
} elseif (stripos($line, 'PresharedKey') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$psk = $v;
|
||||
} elseif (stripos($line, 'Address') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$address = $v;
|
||||
} elseif (stripos($line, 'AllowedIPs') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$allowedIps = array_map('trim', preg_split('/[,\s]+/', $v));
|
||||
} elseif (stripos($line, 'PersistentKeepalive') === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$keepAlive = (int) $v;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$endpointPort) {
|
||||
$endpointPort = 51820;
|
||||
}
|
||||
if (!$mtu) {
|
||||
$mtu = 1280;
|
||||
}
|
||||
if (!$keepAlive) {
|
||||
$keepAlive = 25;
|
||||
}
|
||||
$dns1 = $dns[0] ?? '1.1.1.1';
|
||||
$dns2 = $dns[1] ?? '1.0.0.1';
|
||||
|
||||
// Derive client public key if sodium available
|
||||
$clientPubKey = '';
|
||||
if ($privKey && function_exists('sodium_crypto_scalarmult_base')) {
|
||||
$bin = base64_decode($privKey, true);
|
||||
if ($bin !== false && strlen($bin) === 32) {
|
||||
$pub = sodium_crypto_scalarmult_base($bin);
|
||||
$clientPubKey = base64_encode($pub);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect obfuscation params from conf if present
|
||||
$params = [
|
||||
'H1' => null,
|
||||
'H2' => null,
|
||||
'H3' => null,
|
||||
'H4' => null,
|
||||
'Jc' => null,
|
||||
'Jmin' => null,
|
||||
'Jmax' => null,
|
||||
'S1' => null,
|
||||
'S2' => null,
|
||||
];
|
||||
foreach (explode("\n", $conf) as $line) {
|
||||
$line = trim($line);
|
||||
foreach (array_keys($params) as $k) {
|
||||
if (stripos($line, $k) === 0 && strpos($line, '=') !== false) {
|
||||
[, $v] = array_map('trim', explode('=', $line, 2));
|
||||
$params[$k] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build last_config JSON object (stringified, pretty-printed)
|
||||
$lastConfigObj = [
|
||||
'H1' => (string) ($params['H1'] ?? ''),
|
||||
'H2' => (string) ($params['H2'] ?? ''),
|
||||
'H3' => (string) ($params['H3'] ?? ''),
|
||||
'H4' => (string) ($params['H4'] ?? ''),
|
||||
'Jc' => (string) ($params['Jc'] ?? ''),
|
||||
'Jmax' => (string) ($params['Jmax'] ?? ''),
|
||||
'Jmin' => (string) ($params['Jmin'] ?? ''),
|
||||
'S1' => (string) ($params['S1'] ?? ''),
|
||||
'S2' => (string) ($params['S2'] ?? ''),
|
||||
'allowed_ips' => $allowedIps ?: ['0.0.0.0/0', '::/0'],
|
||||
'clientId' => $clientPubKey ?: '',
|
||||
'client_ip' => preg_replace('/\/(\d{1,2})$/', '', (string) ($address ?? '')),
|
||||
'client_priv_key' => (string) ($privKey ?? ''),
|
||||
'client_pub_key' => $clientPubKey ?: '',
|
||||
'config' => $conf,
|
||||
'hostName' => (string) ($endpointHost ?? ''),
|
||||
'mtu' => (string) $mtu,
|
||||
'persistent_keep_alive' => (string) $keepAlive,
|
||||
'port' => $endpointPort,
|
||||
'psk_key' => (string) ($psk ?? ''),
|
||||
'server_pub_key' => (string) ($pubKeyServer ?? ''),
|
||||
];
|
||||
|
||||
$serverDesc = self::resolveServerDescription($endpointHost);
|
||||
@@ -202,17 +377,17 @@ class QrUtil {
|
||||
[
|
||||
// awg first, then container (as in the working QR)
|
||||
'awg' => [
|
||||
'H1' => (string)($params['H1'] ?? ''),
|
||||
'H2' => (string)($params['H2'] ?? ''),
|
||||
'H3' => (string)($params['H3'] ?? ''),
|
||||
'H4' => (string)($params['H4'] ?? ''),
|
||||
'Jc' => (string)($params['Jc'] ?? ''),
|
||||
'Jmax' => (string)($params['Jmax'] ?? ''),
|
||||
'Jmin' => (string)($params['Jmin'] ?? ''),
|
||||
'S1' => (string)($params['S1'] ?? ''),
|
||||
'S2' => (string)($params['S2'] ?? ''),
|
||||
'H1' => (string) ($params['H1'] ?? ''),
|
||||
'H2' => (string) ($params['H2'] ?? ''),
|
||||
'H3' => (string) ($params['H3'] ?? ''),
|
||||
'H4' => (string) ($params['H4'] ?? ''),
|
||||
'Jc' => (string) ($params['Jc'] ?? ''),
|
||||
'Jmax' => (string) ($params['Jmax'] ?? ''),
|
||||
'Jmin' => (string) ($params['Jmin'] ?? ''),
|
||||
'S1' => (string) ($params['S1'] ?? ''),
|
||||
'S2' => (string) ($params['S2'] ?? ''),
|
||||
'last_config' => json_encode($lastConfigObj, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT),
|
||||
'port' => (string)$endpointPort,
|
||||
'port' => (string) $endpointPort,
|
||||
'transport_proto' => 'udp',
|
||||
],
|
||||
'container' => 'amnezia-awg',
|
||||
@@ -227,9 +402,78 @@ class QrUtil {
|
||||
return $envelope;
|
||||
}
|
||||
|
||||
private static function normalizeJson(string $text): string {
|
||||
private static function normalizeJson(string $text): string
|
||||
{
|
||||
$decoded = json_decode($text, true);
|
||||
if (!is_array($decoded)) throw new InvalidArgumentException('Invalid JSON');
|
||||
if (!is_array($decoded))
|
||||
throw new InvalidArgumentException('Invalid JSON');
|
||||
return json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
public static function encodeXrayPayload(string $host, int $port, string $clientId, string $description = '', ?array $reality = null): string
|
||||
{
|
||||
$desc = $description !== '' ? $description : self::resolveServerDescription($host);
|
||||
$clientCfg = [
|
||||
'log' => ['loglevel' => 'error'],
|
||||
'inbounds' => [
|
||||
[
|
||||
'listen' => '127.0.0.1',
|
||||
'port' => 10808,
|
||||
'protocol' => 'socks',
|
||||
'settings' => ['udp' => true]
|
||||
]
|
||||
],
|
||||
'outbounds' => [
|
||||
[
|
||||
'protocol' => 'vless',
|
||||
'settings' => [
|
||||
'vnext' => [
|
||||
[
|
||||
'address' => $host,
|
||||
'port' => $port,
|
||||
'users' => [
|
||||
[
|
||||
'id' => $clientId,
|
||||
'flow' => ($reality && isset($reality['publicKey']) && $reality['publicKey'] !== '') ? 'xtls-rprx-vision' : null,
|
||||
'encryption' => 'none'
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'streamSettings' => [
|
||||
'network' => 'tcp',
|
||||
'security' => ($reality && isset($reality['publicKey']) && $reality['publicKey'] !== '') ? 'reality' : 'none',
|
||||
'realitySettings' => ($reality && isset($reality['publicKey']) && $reality['publicKey'] !== '') ? [
|
||||
'fingerprint' => 'chrome',
|
||||
'serverName' => (string) ($reality['serverName'] ?? $host),
|
||||
'publicKey' => (string) $reality['publicKey'],
|
||||
'shortId' => (string) ($reality['shortId'] ?? ''),
|
||||
'spiderX' => ''
|
||||
] : null
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$envelope = [
|
||||
'containers' => [
|
||||
[
|
||||
'container' => 'amnezia-xray',
|
||||
'xray' => [
|
||||
'last_config' => json_encode($clientCfg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT),
|
||||
'port' => (string) $port,
|
||||
'transport_proto' => 'tcp'
|
||||
]
|
||||
]
|
||||
],
|
||||
'defaultContainer' => 'amnezia-xray',
|
||||
'description' => $desc,
|
||||
'dns1' => '1.1.1.1',
|
||||
'dns2' => '1.0.0.1',
|
||||
'hostName' => $host,
|
||||
];
|
||||
|
||||
return self::encodeOldPayloadFromJson(json_encode($envelope, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ class Router {
|
||||
}
|
||||
public static function get(string $pattern, callable $handler): void { self::add('GET', $pattern, $handler); }
|
||||
public static function post(string $pattern, callable $handler): void { self::add('POST', $pattern, $handler); }
|
||||
public static function put(string $pattern, callable $handler): void { self::add('PUT', $pattern, $handler); }
|
||||
public static function delete(string $pattern, callable $handler): void { self::add('DELETE', $pattern, $handler); }
|
||||
|
||||
private static function normalizePattern(string $pattern): string {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
use Twig\Environment;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
use Twig\TwigFunction;
|
||||
use Twig\TwigFilter;
|
||||
|
||||
class View {
|
||||
private static ?Environment $twig = null;
|
||||
@@ -36,6 +37,22 @@ class View {
|
||||
});
|
||||
self::$twig->addFunction($flagFunc);
|
||||
|
||||
// Add bytes format filter
|
||||
$bytesFilter = new TwigFilter('bytes_format', function (int $bytes, int $precision = 2): string {
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
|
||||
$bytes /= 1024;
|
||||
}
|
||||
return round($bytes, $precision) . ' ' . $units[$i];
|
||||
});
|
||||
self::$twig->addFilter($bytesFilter);
|
||||
|
||||
// Add translation filter (alias: trans)
|
||||
$transFilter = new TwigFilter('trans', function (string $key, array $params = []) {
|
||||
return Translator::t($key, $params);
|
||||
});
|
||||
self::$twig->addFilter($transFilter);
|
||||
|
||||
// Add globals
|
||||
foreach ($globals as $k => $v) self::$twig->addGlobal($k, $v);
|
||||
}
|
||||
|
||||
+1127
-267
File diff suppressed because it is too large
Load Diff
+459
-151
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user