diff --git a/.github/README.md b/.github/README.md index e759d38ac..cb95945b3 100755 --- a/.github/README.md +++ b/.github/README.md @@ -47,19 +47,19 @@ For installation instructions, system requirements, and complete guides, please -_Last updated: 2026-05-14T21:32:57.470Z_ +_Last updated: 2026-05-20T18:10:33.759Z_ | Extension | Files | Lines | | --- | ---: | ---: | -| `.php` | 504 | 126,711 | -| `.tsx` | 355 | 112,571 | -| `.ts` | 70 | 7,661 | -| `.yaml` | 3 | 5,940 | +| `.php` | 518 | 132,170 | +| `.tsx` | 368 | 118,043 | +| `.ts` | 77 | 9,333 | +| `.yaml` | 3 | 5,950 | | `.rs` | 16 | 3,395 | -| `.sql` | 130 | 2,034 | +| `.sql` | 140 | 2,403 | | `.yml` | 18 | 1,877 | | `.css` | 7 | 445 | -| **Total** | 1,103 | 260,634 | +| **Total** | 1,147 | 273,616 | diff --git a/.gitignore b/.gitignore index 362dd3165..dc22b2f3b 100755 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ TODO.txt .install_mode todo.txt backend/storage/config/panel_integrity_baseline.json -build.log \ No newline at end of file +build.log +crack/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b58f05552..993cadaac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## v1.3.7.3 STABLE + +### Added + +- Plugin sidebar priority system by to control the order of the sidebar items. by @nayskutzu +- Verify button on the admin user edit page to force verify the user's email by @nayskutzu +- Verification email resend system in case the user didn't receive the email by @nayskutzu +- Custom server variables system so users can set variables for their servers and also encrypt them. by @nayskutzu +- GDPR compliance system for user data export by @nayskutzu +- Per-user timezone preference in account settings, with browser auto-detection on first visit, so all dates and "X ago" labels render in each user's preferred zone. by @nayskutzu +- Per-server-schedule timezone selector. Each cron schedule now stores its own IANA timezone by @nayskutzu +- Server archives can now be opened and even viewed in the file manager. by @nayskutzu +- Trash bin support in the file manager. by @nayskutzu +- Preview support for archive contents in the file manager. by @nayskutzu +- Added detailed ban and suspension reason tracking for both servers and users, providing greater clarity for the staff team. by @nayskutzu +- Added a copyright notice to acknowledge the creators of the panel. by @nayskutzu +- Introduced a sleek and intuitive server switcher for seamless navigation between servers. by @nayskutzu +- Mass move servers from a node to another with automatic allocation assignment, selectable servers or move-all, and a per-request batch limit for large fleets. by @nayskutzu +- Custom sort order for spells within a realm so admins can control display order (e.g. Node.js before Python before Java). by @nayskutzu + +### Fixed + +- FIFO (First In, First Out) rotation scheme for log file rotation and cleanup. by @nayskutzu +- Resolved an issue where your avatar would reload 100 times when you type in the chat box. by @nayskutzu +- Admins with admin.dashboard.view permission can now view the dashboard. by @nayskutzu +- Fixed an issue where the chatbot would sometimes get stuck in a loop when trying to do stuff. by @nayskutzu +- Added missing translations to the translations file. by @nayskutzu +- The default theme from admin was not applied if you didn't lock it. by @nayskutzu +- Buch of timezone issues were fixed. by @nayskutzu +- Color issues with vds settings page were fixed. by @nayskutzu +- Further enhanced the plugin installer for improved performance and reliability. by @nayskutzu +- Resolved a significant issue that previously prevented searching for allocations within the admin area. by @nayskutzu + +### Improved + +- Tickets were pretty cramped and didn't look good on big screens. by @nayskutzu +- Chatbot now supports the ability to get the article content by its ID. by @nayskutzu +- Chatbot token usage has been cut in half by @nayskutzu +- Enhanced Minecraft server compatibility and features to fully support the latest release version. by @nayskutzu +- Added a white (light) mode option for Wings server configuration files, enhancing visibility and user experience in brighter environments. by @nayskutzu +- Enhanced the console layout for mobile devices, offering a smoother and more intuitive user experience. by @nayskutzu + ## v1.3.7.2 STABLE ### Fixed diff --git a/app b/app index 57b5c3686..2fbc3bcd6 100755 --- a/app +++ b/app @@ -48,7 +48,7 @@ define('APP_ADDONS_DIR', APP_STORAGE_DIR . 'addons'); define('APP_SOURCECODE_DIR', APP_DIR . 'app'); define('APP_ROUTES_DIR', APP_SOURCECODE_DIR . '/Api'); define('SYSTEM_KERNEL_NAME', php_uname('s')); -define('APP_VERSION', 'v1.3.7.2'); +define('APP_VERSION', 'v1.3.7.3'); define('APP_UPSTREAM', 'stable'); define('TELEMETRY', true); define('IS_CLI', true); diff --git a/backend/app/App.php b/backend/app/App.php index 2dc8e9a45..577ef9ae0 100755 --- a/backend/app/App.php +++ b/backend/app/App.php @@ -161,11 +161,20 @@ public function __construct(bool $softBoot, bool $isCron = false, bool $isCli = return; } - $timezone = $this->getConfig()->getSetting(ConfigInterface::APP_TIMEZONE, 'UTC'); - if (!@date_default_timezone_set($timezone)) { - self::getLogger()->warning("Invalid timezone '$timezone', falling back to UTC."); - date_default_timezone_set('UTC'); - } + // Force PHP's default timezone to UTC for the entire request. + // + // Every datetime that gets persisted to the database is generated from + // PHP's `date()`/`DateTime`/`->format(...)` family of helpers, all of + // which honour `date_default_timezone_set`. If we leave this on the + // admin-configured panel timezone (e.g. Europe/Paris), those helpers + // emit local-time literals, MySQL stores them verbatim into DATETIME + // columns, and the frontend — which now correctly interprets every API + // datetime as UTC — ends up displaying them shifted by the panel + // offset (e.g. a row created "now" gets labelled "in 2 hours"). + // + // The `app_timezone` setting is retained as a display fallback in the + // frontend, where it belongs. Storage is always UTC. + date_default_timezone_set('UTC'); $this->routes = new RouteCollection(); $this->registerApiRoutes($this->routes); diff --git a/backend/app/Chat/Allocation.php b/backend/app/Chat/Allocation.php index 7c5e57462..1f81535f6 100755 --- a/backend/app/Chat/Allocation.php +++ b/backend/app/Chat/Allocation.php @@ -215,6 +215,65 @@ public static function getAvailableCount(): int return (int) $stmt->fetchColumn(); } + /** + * Count free (unassigned) allocations on a specific node. + */ + public static function getFreeCountByNodeId(int $nodeId): int + { + if ($nodeId <= 0) { + return 0; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare('SELECT COUNT(*) FROM ' . self::$table . ' WHERE node_id = :node_id AND server_id IS NULL'); + $stmt->execute(['node_id' => $nodeId]); + + return (int) $stmt->fetchColumn(); + } + + /** + * Pick free allocation IDs on a node for assignment (e.g. server transfers). + * + * @param array $excludeIds Allocation IDs to skip (already reserved in the same batch) + * + * @return array + */ + public static function pickFreeAllocationIdsForNode(int $nodeId, int $count, array $excludeIds = []): array + { + if ($nodeId <= 0 || $count <= 0) { + return []; + } + + $pdo = Database::getPdoConnection(); + $sql = 'SELECT id FROM ' . self::$table . ' WHERE node_id = :node_id AND server_id IS NULL'; + $params = ['node_id' => $nodeId]; + + $excludeIds = array_values(array_filter( + array_map('intval', $excludeIds), + fn (int $id) => $id > 0 + )); + + if (!empty($excludeIds)) { + $placeholders = implode(',', array_fill(0, count($excludeIds), '?')); + $sql .= ' AND id NOT IN (' . $placeholders . ')'; + } + + $sql .= ' ORDER BY ip ASC, port ASC LIMIT ' . (int) $count; + + $stmt = $pdo->prepare($sql); + $stmt->bindValue('node_id', $nodeId, \PDO::PARAM_INT); + + $paramIndex = 1; + foreach ($excludeIds as $excludeId) { + $stmt->bindValue($paramIndex, $excludeId, \PDO::PARAM_INT); + ++$paramIndex; + } + + $stmt->execute(); + + return array_map('intval', $stmt->fetchAll(\PDO::FETCH_COLUMN)); + } + /** * Create a new allocation. */ @@ -569,6 +628,135 @@ public static function isUniqueIpPort(int $nodeId, string $ip, int $port, ?int $ return (int) $stmt->fetchColumn() === 0; } + /** + * Count allocations matching a node and IP address. + */ + public static function countByNodeAndIp(int $nodeId, string $ip): int + { + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare('SELECT COUNT(*) FROM ' . self::$table . ' WHERE node_id = :node_id AND ip = :ip'); + $stmt->execute([ + 'node_id' => $nodeId, + 'ip' => $ip, + ]); + + return (int) $stmt->fetchColumn(); + } + + /** + * Update the IP and/or IP alias for every allocation on a node using a specific IP. + */ + public static function updateAddressByNodeAndIp(int $nodeId, string $fromIp, ?string $toIp, mixed $ipAlias, bool $updateAlias): array | false + { + $set = []; + $params = [ + 'node_id' => $nodeId, + 'from_ip' => $fromIp, + ]; + + if ($toIp !== null) { + $set[] = 'ip = :to_ip'; + $params['to_ip'] = $toIp; + } + + if ($updateAlias) { + $set[] = 'ip_alias = :ip_alias'; + $params['ip_alias'] = $ipAlias; + } + + if (empty($set)) { + return false; + } + + $pdo = Database::getPdoConnection(); + $deletedTargetConflicts = 0; + $deletedSourceConflicts = 0; + $assignedConflictCount = 0; + + try { + $pdo->beginTransaction(); + + if ($toIp !== null && $toIp !== $fromIp) { + $conflictStmt = $pdo->prepare(' + SELECT + src.id AS source_id, + src.server_id AS source_server_id, + dst.id AS target_id, + dst.server_id AS target_server_id + FROM ' . self::$table . ' src + INNER JOIN ' . self::$table . ' dst + ON dst.node_id = src.node_id + AND dst.ip = :to_ip + AND dst.port = src.port + WHERE src.node_id = :node_id + AND src.ip = :from_ip + '); + $conflictStmt->execute([ + 'node_id' => $nodeId, + 'from_ip' => $fromIp, + 'to_ip' => $toIp, + ]); + $conflicts = $conflictStmt->fetchAll(\PDO::FETCH_ASSOC); + + $deleteTargetStmt = $pdo->prepare('DELETE FROM ' . self::$table . ' WHERE id = :id AND server_id IS NULL'); + $deleteSourceStmt = $pdo->prepare('DELETE FROM ' . self::$table . ' WHERE id = :id AND server_id IS NULL'); + $updateTargetAliasStmt = $pdo->prepare('UPDATE ' . self::$table . ' SET ip_alias = :ip_alias WHERE id = :id'); + + foreach ($conflicts as $conflict) { + $sourceAssigned = $conflict['source_server_id'] !== null; + $targetAssigned = $conflict['target_server_id'] !== null; + + if ($sourceAssigned && $targetAssigned) { + ++$assignedConflictCount; + continue; + } + + if (!$targetAssigned) { + $deleteTargetStmt->execute(['id' => (int) $conflict['target_id']]); + $deletedTargetConflicts += $deleteTargetStmt->rowCount(); + } else { + $deleteSourceStmt->execute(['id' => (int) $conflict['source_id']]); + $deletedSourceConflicts += $deleteSourceStmt->rowCount(); + + if ($updateAlias) { + $updateTargetAliasStmt->execute([ + 'id' => (int) $conflict['target_id'], + 'ip_alias' => $ipAlias, + ]); + } + } + } + + if ($assignedConflictCount > 0) { + $pdo->rollBack(); + + return [ + 'assigned_conflict_count' => $assignedConflictCount, + ]; + } + } + + $stmt = $pdo->prepare('UPDATE ' . self::$table . ' SET ' . implode(', ', $set) . ' WHERE node_id = :node_id AND ip = :from_ip'); + $stmt->execute($params); + $updatedCount = $stmt->rowCount(); + $pdo->commit(); + + return [ + 'updated_count' => $updatedCount, + 'deleted_target_conflicts' => $deletedTargetConflicts, + 'deleted_source_conflicts' => $deletedSourceConflicts, + 'assigned_conflict_count' => 0, + ]; + } catch (\Exception $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + App::getInstance(true)->getLogger()->error('Failed to bulk update allocation address: ' . $e->getMessage()); + + return false; + } + } + /** * Get allocation with node information. */ diff --git a/backend/app/Chat/ChatConversation.php b/backend/app/Chat/ChatConversation.php index 279f4f206..68da7b26e 100755 --- a/backend/app/Chat/ChatConversation.php +++ b/backend/app/Chat/ChatConversation.php @@ -22,6 +22,7 @@ class ChatConversation { private static string $table = 'featherpanel_chatbot_conversations'; + private static ?array $columns = null; /** * Create a new conversation. @@ -94,6 +95,10 @@ public static function updateConversation(int $id, array $data): bool } $pdo = Database::getPdoConnection(); + $data = array_intersect_key($data, array_flip(self::getColumns())); + if (empty($data)) { + return false; + } $fields = array_keys($data); $set = implode(', ', array_map(fn ($f) => "$f = :$f", $fields)); $sql = 'UPDATE ' . self::$table . ' SET ' . $set . ' WHERE id = :id'; @@ -144,4 +149,22 @@ public static function deleteUserConversations(string $userUuid): bool return false; } } + + public static function hasColumn(string $column): bool + { + return in_array($column, self::getColumns(), true); + } + + private static function getColumns(): array + { + if (self::$columns !== null) { + return self::$columns; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->query('SHOW COLUMNS FROM ' . self::$table); + self::$columns = array_map(static fn (array $column): string => $column['Field'], $stmt->fetchAll(\PDO::FETCH_ASSOC)); + + return self::$columns; + } } diff --git a/backend/app/Chat/ChatMessage.php b/backend/app/Chat/ChatMessage.php index 145b54f49..24dd912eb 100755 --- a/backend/app/Chat/ChatMessage.php +++ b/backend/app/Chat/ChatMessage.php @@ -22,6 +22,7 @@ class ChatMessage { private static string $table = 'featherpanel_chatbot_messages'; + private static ?array $columns = null; /** * Create a new message. @@ -32,8 +33,18 @@ class ChatMessage */ public static function createMessage(array $data): int | false { + foreach (['tool_activity', 'usage_json'] as $jsonField) { + if (isset($data[$jsonField]) && is_array($data[$jsonField])) { + $data[$jsonField] = json_encode($data[$jsonField]); + } + } + $pdo = Database::getPdoConnection(); + $data = array_intersect_key($data, array_flip(self::getColumns())); $fields = array_keys($data); + if (empty($fields)) { + return false; + } $placeholders = array_map(fn ($f) => ':' . $f, $fields); $sql = 'INSERT INTO ' . self::$table . ' (' . implode(',', $fields) . ') VALUES (' . implode(',', $placeholders) . ')'; $stmt = $pdo->prepare($sql); @@ -60,7 +71,7 @@ public static function getMessagesByConversation(int $conversationId, int $limit $stmt->bindValue(':limit', $limit, \PDO::PARAM_INT); $stmt->execute(); - return $stmt->fetchAll(\PDO::FETCH_ASSOC); + return array_map([self::class, 'normalizeMessage'], $stmt->fetchAll(\PDO::FETCH_ASSOC)); } /** @@ -92,7 +103,9 @@ public static function getMessageById(int $id): ?array $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE id = :id LIMIT 1'); $stmt->execute(['id' => $id]); - return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + $message = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $message ? self::normalizeMessage($message) : null; } /** @@ -115,4 +128,37 @@ public static function deleteMessagesByConversation(int $conversationId): bool return false; } } + + private static function normalizeMessage(array $message): array + { + foreach (['input_tokens', 'output_tokens', 'total_tokens'] as $field) { + if (array_key_exists($field, $message)) { + $message[$field] = $message[$field] !== null ? (int) $message[$field] : null; + } + } + + foreach (['tool_activity', 'usage_json'] as $field) { + if (!empty($message[$field]) && is_string($message[$field])) { + $decoded = json_decode($message[$field], true); + $message[$field] = json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } elseif (!array_key_exists($field, $message)) { + $message[$field] = null; + } + } + + return $message; + } + + private static function getColumns(): array + { + if (self::$columns !== null) { + return self::$columns; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->query('SHOW COLUMNS FROM ' . self::$table); + self::$columns = array_map(static fn (array $column): string => $column['Field'], $stmt->fetchAll(\PDO::FETCH_ASSOC)); + + return self::$columns; + } } diff --git a/backend/app/Chat/Database.php b/backend/app/Chat/Database.php index 3a33852cd..704b7f4b5 100755 --- a/backend/app/Chat/Database.php +++ b/backend/app/Chat/Database.php @@ -46,6 +46,20 @@ public function __construct($host, $dbName, $username = null, $password = null, try { $this->pdo = new \PDO($dsn, $username, $password); $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + + // Force the MySQL session timezone to UTC so that CURRENT_TIMESTAMP / NOW() + // and other server-side time functions are deterministic regardless of the + // operating system or container default. All datetime columns in the schema + // are stored as UTC and converted to the user's preferred timezone at + // display time on the frontend. + try { + $this->pdo->exec("SET time_zone = '+00:00'"); + } catch (\PDOException $tzException) { + // Non-fatal: log and continue. Some constrained MySQL deployments may + // not allow SET time_zone; behaviour falls back to the previous (buggy) + // semantics in that case rather than refusing to boot. + error_log('Failed to set MySQL session timezone to UTC: ' . $tzException->getMessage()); + } } catch (\PDOException $e) { throw new \Exception('Connection failed: ' . $e->getMessage()); } diff --git a/backend/app/Chat/ServerCustomVariable.php b/backend/app/Chat/ServerCustomVariable.php new file mode 100644 index 000000000..38050a354 --- /dev/null +++ b/backend/app/Chat/ServerCustomVariable.php @@ -0,0 +1,209 @@ +. + */ + +namespace App\Chat; + +use App\App; + +/** + * ServerCustomVariable service/model for user-managed server environment variables. + */ +class ServerCustomVariable +{ + private static string $table = 'featherpanel_server_custom_variables'; + + private static array $allowedFields = [ + 'server_id', + 'user_id', + 'name', + 'env_variable', + 'variable_value', + 'is_encrypted', + ]; + + public static function createCustomVariable(array $data): int | false + { + $required = ['server_id', 'user_id', 'name', 'env_variable', 'variable_value']; + foreach ($required as $field) { + if (!isset($data[$field])) { + App::getInstance(true)->getLogger()->error('Missing required field for custom server variable: ' . $field); + + return false; + } + } + + if (!is_numeric($data['server_id']) || (int) $data['server_id'] <= 0 || !Server::getServerById((int) $data['server_id'])) { + return false; + } + + if (!is_numeric($data['user_id']) || (int) $data['user_id'] <= 0 || !User::getUserById((int) $data['user_id'])) { + return false; + } + + $data['name'] = trim((string) $data['name']); + $data['env_variable'] = strtoupper(trim((string) $data['env_variable'])); + $data['variable_value'] = (string) $data['variable_value']; + $data['is_encrypted'] = isset($data['is_encrypted']) && filter_var($data['is_encrypted'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; + + if ($data['name'] === '' || strlen($data['name']) > 191) { + return false; + } + + if (!self::isValidEnvVariable($data['env_variable']) || strlen($data['env_variable']) > 191) { + return false; + } + + if (self::envVariableExists((int) $data['server_id'], $data['env_variable'])) { + return false; + } + + if ((int) $data['is_encrypted'] === 1) { + $data['variable_value'] = App::getInstance(true)->encryptValue($data['variable_value']); + } + + $filteredData = array_intersect_key($data, array_flip(self::$allowedFields)); + + $pdo = Database::getPdoConnection(); + $fields = array_keys($filteredData); + $placeholders = array_map(fn ($f) => ':' . $f, $fields); + $sql = 'INSERT INTO ' . self::$table . ' (' . implode(',', $fields) . ') VALUES (' . implode(',', $placeholders) . ')'; + $stmt = $pdo->prepare($sql); + + if ($stmt->execute($filteredData)) { + return (int) $pdo->lastInsertId(); + } + + return false; + } + + public static function getCustomVariableById(int $id, bool $revealEncrypted = false): ?array + { + if ($id <= 0) { + return null; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE id = :id LIMIT 1'); + $stmt->execute(['id' => $id]); + + $row = $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + + return $row ? self::prepareForOutput($row, $revealEncrypted) : null; + } + + public static function getCustomVariablesByServerId(int $serverId, bool $revealEncrypted = false): array + { + if ($serverId <= 0) { + return []; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE server_id = :server_id ORDER BY id ASC'); + $stmt->execute(['server_id' => $serverId]); + + return array_map( + fn (array $row) => self::prepareForOutput($row, $revealEncrypted), + $stmt->fetchAll(\PDO::FETCH_ASSOC) + ); + } + + public static function getEnvironmentVariablesByServerId(int $serverId): array + { + $variables = []; + foreach (self::getCustomVariablesByServerId($serverId, true) as $variable) { + $variables[$variable['env_variable']] = $variable['variable_value']; + } + + return $variables; + } + + public static function deleteCustomVariableForServer(int $id, int $serverId): bool + { + if ($id <= 0 || $serverId <= 0) { + return false; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare('DELETE FROM ' . self::$table . ' WHERE id = :id AND server_id = :server_id'); + $stmt->execute([ + 'id' => $id, + 'server_id' => $serverId, + ]); + + return $stmt->rowCount() > 0; + } + + public static function envVariableExists(int $serverId, string $envVariable): bool + { + if ($serverId <= 0 || trim($envVariable) === '') { + return false; + } + + $envVariable = strtoupper(trim($envVariable)); + $server = Server::getServerById($serverId); + if (!$server) { + return false; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare('SELECT COUNT(*) FROM ' . self::$table . ' WHERE server_id = :server_id AND env_variable = :env_variable'); + $stmt->execute([ + 'server_id' => $serverId, + 'env_variable' => $envVariable, + ]); + + if ((int) $stmt->fetchColumn() > 0) { + return true; + } + + $stmt = $pdo->prepare('SELECT COUNT(*) FROM featherpanel_spell_variables WHERE spell_id = :spell_id AND env_variable = :env_variable'); + $stmt->execute([ + 'spell_id' => (int) $server['spell_id'], + 'env_variable' => $envVariable, + ]); + + return (int) $stmt->fetchColumn() > 0; + } + + public static function isValidEnvVariable(string $envVariable): bool + { + return preg_match('/^[A-Z_][A-Z0-9_]*$/', $envVariable) === 1; + } + + private static function prepareForOutput(array $row, bool $revealEncrypted): array + { + $row['is_encrypted'] = (int) ($row['is_encrypted'] ?? 0); + if ($row['is_encrypted'] !== 1) { + return $row; + } + + if ($revealEncrypted) { + try { + $row['variable_value'] = App::getInstance(true)->decryptValue((string) $row['variable_value']); + } catch (\Throwable $e) { + App::getInstance(true)->getLogger()->error('Failed to decrypt custom server variable: ' . $e->getMessage()); + $row['variable_value'] = ''; + } + + return $row; + } + + $row['variable_value'] = '********'; + + return $row; + } +} diff --git a/backend/app/Chat/ServerSchedule.php b/backend/app/Chat/ServerSchedule.php index e8f972525..2e4e7af3c 100755 --- a/backend/app/Chat/ServerSchedule.php +++ b/backend/app/Chat/ServerSchedule.php @@ -209,13 +209,16 @@ public static function resetStuckProcessing(int $stuckAfterMinutes = 15): int /** * Get schedules that are due to run. - * Uses PHP's current time (app timezone) for comparison so timezone matches - * the one used when calculating next_run_at, avoiding instant execution. + * + * Comparison is done in UTC: PHP's `date()` is pinned to UTC by App.php + * and `next_run_at` is persisted as a UTC literal by + * {@see calculateNextRunTime()}, so this query is timezone-deterministic + * regardless of the schedule's authoring zone. */ public static function getDueSchedules(): array { $pdo = Database::getPdoConnection(); - $now = date('Y-m-d H:i:s'); + $now = gmdate('Y-m-d H:i:s'); $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE is_active = 1 AND next_run_at <= :now AND is_processing = 0'); $stmt->bindValue(':now', $now, \PDO::PARAM_STR); $stmt->execute(); @@ -542,26 +545,37 @@ public static function validateCronExpression(string $dayOfWeek, string $month, } /** - * Calculate next run time based on cron expression. + * Calculate next run time based on cron expression, evaluated in the + * caller-supplied timezone, returned as a literal UTC `Y-m-d H:i:s` string + * suitable for storing in a `TIMESTAMP next_run_at` column. + * + * The cron expression is interpreted in `$timezone` (e.g. "Europe/Paris"), + * so "0 3 * * *" means 03:00 local for the schedule owner — not 03:00 UTC. + * The returned literal is in UTC so that the database comparison + * `next_run_at <= UTC_TIMESTAMP()` in `getDueSchedules()` is sound + * regardless of which zone the schedule was authored in. * * @param string $dayOfWeek Cron expression day of week component * @param string $month Cron expression month component * @param string $dayOfMonth Cron expression day of month component * @param string $hour Cron expression hour component * @param string $minute Cron expression minute component - * @param string|null $referenceTime Optional reference time (e.g. current next_run_at) to maintain cadence + * @param string|null $referenceTime Optional reference time (UTC literal) to maintain cadence + * @param string $timezone IANA timezone name the cron expression is authored in (default: UTC) */ - public static function calculateNextRunTime(string $dayOfWeek, string $month, string $dayOfMonth, string $hour, string $minute, ?string $referenceTime = null): string + public static function calculateNextRunTime(string $dayOfWeek, string $month, string $dayOfMonth, string $hour, string $minute, ?string $referenceTime = null, string $timezone = 'UTC'): string { $expression = self::formatCronExpression($dayOfWeek, $month, $dayOfMonth, $hour, $minute); + $tz = self::resolveTimezone($timezone); + $utc = new \DateTimeZone('UTC'); if (class_exists(CrontabSchedule::class)) { try { $schedule = new CrontabSchedule($expression); if (method_exists($schedule, 'getNextRunDate')) { - $base = self::resolveBaseDateTime($referenceTime); + $base = self::resolveBaseDateTime($referenceTime, $tz); $nextRun = $schedule->getNextRunDate($base, 0, false); - $now = new \DateTime(); + $now = new \DateTime('now', $tz); // Ensure next run is strictly in the future to avoid schedules running instantly if ($nextRun <= $now) { @@ -569,14 +583,48 @@ public static function calculateNextRunTime(string $dayOfWeek, string $month, st $nextRun = $schedule->getNextRunDate($nextRun, 0, false); } - return $nextRun->format('Y-m-d H:i:s'); + // Persist as UTC literal so DB comparisons stay deterministic. + return $nextRun->setTimezone($utc)->format('Y-m-d H:i:s'); } } catch (\Throwable $e) { App::getInstance(true)->getLogger()->error('Failed to calculate next run time for expression ' . $expression . ': ' . $e->getMessage()); } } - return self::calculateNextRunTimeFallback($dayOfWeek, $month, $dayOfMonth, $hour, $minute, $referenceTime); + return self::calculateNextRunTimeFallback($dayOfWeek, $month, $dayOfMonth, $hour, $minute, $referenceTime, $timezone); + } + + /** + * Resolve a user-supplied timezone string into a DateTimeZone, falling + * back to UTC if the identifier is invalid. + */ + public static function resolveTimezone(?string $timezone): \DateTimeZone + { + if ($timezone === null || $timezone === '') { + return new \DateTimeZone('UTC'); + } + try { + return new \DateTimeZone($timezone); + } catch (\Throwable $e) { + return new \DateTimeZone('UTC'); + } + } + + /** + * Check that a timezone identifier is a valid IANA name supported by PHP. + */ + public static function isValidTimezone(?string $timezone): bool + { + if (!is_string($timezone) || $timezone === '') { + return false; + } + try { + new \DateTimeZone($timezone); + + return true; + } catch (\Throwable $e) { + return false; + } } /** @@ -588,16 +636,21 @@ private static function formatCronExpression(string $dayOfWeek, string $month, s } /** - * Resolve base date time for cron calculation using the greater of now or reference time. + * Resolve base date time for cron calculation using the greater of now or reference time, + * evaluated in the schedule's authoring timezone. + * + * The reference time argument is expected to be a UTC `Y-m-d H:i:s` literal + * (the format we persist `next_run_at` in), so it is parsed as UTC before + * being shifted into the schedule's zone for comparison. */ - private static function resolveBaseDateTime(?string $referenceTime = null): \DateTime + private static function resolveBaseDateTime(?string $referenceTime, \DateTimeZone $tz): \DateTime { - $currentTime = new \DateTime(); - $base = clone $currentTime; + $base = new \DateTime('now', $tz); if ($referenceTime !== null) { try { - $reference = new \DateTime($referenceTime); + $reference = new \DateTime($referenceTime, new \DateTimeZone('UTC')); + $reference->setTimezone($tz); if ($reference > $base) { $base = $reference; } @@ -614,14 +667,18 @@ private static function resolveBaseDateTime(?string $referenceTime = null): \Dat /** * Fallback calculation when external cron library is unavailable. */ - private static function calculateNextRunTimeFallback(string $dayOfWeek, string $month, string $dayOfMonth, string $hour, string $minute, ?string $referenceTime = null): string + private static function calculateNextRunTimeFallback(string $dayOfWeek, string $month, string $dayOfMonth, string $hour, string $minute, ?string $referenceTime = null, string $timezone = 'UTC'): string { - $currentTime = new \DateTime(); + $tz = self::resolveTimezone($timezone); + $utc = new \DateTimeZone('UTC'); + + $currentTime = new \DateTime('now', $tz); $searchStart = clone $currentTime; if ($referenceTime !== null) { try { - $reference = new \DateTime($referenceTime); + $reference = new \DateTime($referenceTime, $utc); + $reference->setTimezone($tz); if ($reference > $searchStart) { $searchStart = $reference; } @@ -658,7 +715,7 @@ private static function calculateNextRunTimeFallback(string $dayOfWeek, string $ $nextRun->add(new \DateInterval('P1D')); } - return $nextRun->format('Y-m-d H:i:s'); + return $nextRun->setTimezone($utc)->format('Y-m-d H:i:s'); } /** diff --git a/backend/app/Chat/Spell.php b/backend/app/Chat/Spell.php index 8d1efb93c..30bc1c1cc 100755 --- a/backend/app/Chat/Spell.php +++ b/backend/app/Chat/Spell.php @@ -123,6 +123,10 @@ public static function createSpell(array $data): int | false // Handle optional ID for migrations $hasId = isset($data['id']) && is_int($data['id']) && $data['id'] > 0; + if (!isset($data['sort_order'])) { + $data['sort_order'] = self::getNextSortOrderForRealm((int) $data['realm_id']); + } + $pdo = Database::getPdoConnection(); $fields = array_keys($data); $placeholders = array_map(fn ($f) => ':' . $f, $fields); @@ -177,7 +181,7 @@ public static function getSpellsByRealmId(int $realmId): array return []; } $pdo = Database::getPdoConnection(); - $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE realm_id = :realm_id ORDER BY name ASC'); + $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE realm_id = :realm_id ORDER BY sort_order ASC, name ASC'); $stmt->execute(['realm_id' => $realmId]); return $stmt->fetchAll(\PDO::FETCH_ASSOC); @@ -191,13 +195,31 @@ public static function getAllSpells(): array $pdo = Database::getPdoConnection(); $sql = 'SELECT * FROM ' . self::$table; - $sql .= ' ORDER BY name ASC'; + $sql .= ' ORDER BY realm_id ASC, sort_order ASC, name ASC'; $stmt = $pdo->prepare($sql); $stmt->execute(); return $stmt->fetchAll(\PDO::FETCH_ASSOC); } + /** + * Next sort_order value for a new spell in a realm. + */ + public static function getNextSortOrderForRealm(int $realmId): int + { + if ($realmId <= 0) { + return 0; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare( + 'SELECT COALESCE(MAX(sort_order), -10) + 10 FROM ' . self::$table . ' WHERE realm_id = :realm_id' + ); + $stmt->execute(['realm_id' => $realmId]); + + return (int) $stmt->fetchColumn(); + } + /** * Search spells with pagination and filtering. */ @@ -206,7 +228,7 @@ public static function searchSpells( int $limit = 10, string $search = '', array $fields = [], - string $sortBy = 'name', + string $sortBy = 'sort_order', string $sortOrder = 'ASC', ?int $realmId = null, ): array { @@ -228,7 +250,16 @@ public static function searchSpells( $params['realm_id'] = $realmId; } + $allowedSort = ['sort_order', 'name', 'created_at', 'id']; + if (!in_array($sortBy, $allowedSort, true)) { + $sortBy = 'sort_order'; + } + $sortOrder = strtoupper($sortOrder) === 'DESC' ? 'DESC' : 'ASC'; + $sql .= ' ORDER BY s.' . $sortBy . ' ' . $sortOrder; + if ($sortBy !== 'name') { + $sql .= ', s.name ASC'; + } $sql .= ' LIMIT :limit OFFSET :offset'; $stmt = $pdo->prepare($sql); @@ -495,13 +526,70 @@ public static function getAllSpellsWithRealm(): array SELECT s.*, r.name as realm_name, r.description as realm_description FROM ' . self::$table . ' s LEFT JOIN featherpanel_realms r ON s.realm_id = r.id - ORDER BY s.name ASC + ORDER BY s.sort_order ASC, s.name ASC '); $stmt->execute(); return $stmt->fetchAll(\PDO::FETCH_ASSOC); } + /** + * Update sort order for multiple spells (batch reorder within a realm). + * + * @param array $spells Array of ['id' => int, 'sort_order' => int] + * @param int|null $realmId When set, every spell must belong to this realm + */ + public static function updateSortOrders(array $spells, ?int $realmId = null): bool + { + if (empty($spells)) { + return false; + } + + $pdo = Database::getPdoConnection(); + + try { + $pdo->beginTransaction(); + + $stmt = $pdo->prepare('UPDATE ' . self::$table . ' SET sort_order = :sort_order WHERE id = :id'); + + foreach ($spells as $spell) { + if (!isset($spell['id']) || !isset($spell['sort_order'])) { + continue; + } + + $id = (int) $spell['id']; + $sortOrder = (int) $spell['sort_order']; + + if ($id <= 0) { + continue; + } + + if ($realmId !== null) { + $row = self::getSpellById($id); + if (!$row || (int) $row['realm_id'] !== $realmId) { + $pdo->rollBack(); + + return false; + } + } + + $stmt->execute([ + 'id' => $id, + 'sort_order' => $sortOrder, + ]); + } + + $pdo->commit(); + + return true; + } catch (\PDOException $e) { + $pdo->rollBack(); + App::getInstance(true)->getLogger()->error('Failed to update spell sort orders: ' . $e->getMessage()); + + return false; + } + } + public static function count(array $conditions): int { $pdo = Database::getPdoConnection(); diff --git a/backend/app/Chat/User.php b/backend/app/Chat/User.php index da92076fb..5199188f9 100755 --- a/backend/app/Chat/User.php +++ b/backend/app/Chat/User.php @@ -84,7 +84,23 @@ public static function createUser(array $data, bool $skipEmailValidation = false $insert['remember_token'] = $data['remember_token'] ?? self::generateAccountToken(); // Add optional fields if provided - $optionalFields = ['role_id', 'avatar', 'first_ip', 'last_ip', 'banned', 'two_fa_enabled', 'two_fa_key', 'external_id', 'ticket_signature', 'oidc_provider', 'oidc_subject', 'oidc_email', 'mail_verify']; + $optionalFields = [ + 'role_id', + 'avatar', + 'first_ip', + 'last_ip', + 'banned', + 'two_fa_enabled', + 'two_fa_key', + 'external_id', + 'ticket_signature', + 'oidc_provider', + 'oidc_subject', + 'oidc_email', + 'ldap_provider_uuid', + 'ldap_dn', + 'mail_verify', + ]; foreach ($optionalFields as $field) { if (isset($data[$field])) { $insert[$field] = $data[$field]; diff --git a/backend/app/Chat/UserDataExport.php b/backend/app/Chat/UserDataExport.php new file mode 100644 index 000000000..becbe5e56 --- /dev/null +++ b/backend/app/Chat/UserDataExport.php @@ -0,0 +1,276 @@ +. + */ + +namespace App\Chat; + +use App\App; + +/** + * UserDataExport service/model for queued personal data exports. + */ +class UserDataExport +{ + private static string $table = 'featherpanel_user_data_exports'; + + /** + * Create a pending export request. + */ + public static function create(array $data): int | false + { + $required = ['uuid', 'user_uuid', 'ticket_id']; + foreach ($required as $field) { + if (!isset($data[$field]) || (is_string($data[$field]) && trim($data[$field]) === '')) { + App::getInstance(true)->getLogger()->error("Missing required field: $field"); + + return false; + } + } + + if (!preg_match('/^[a-f0-9\-]{36}$/i', (string) $data['uuid'])) { + App::getInstance(true)->getLogger()->error('Invalid data export UUID: ' . $data['uuid']); + + return false; + } + + if (!preg_match('/^[a-f0-9\-]{36}$/i', (string) $data['user_uuid'])) { + App::getInstance(true)->getLogger()->error('Invalid user UUID for data export: ' . $data['user_uuid']); + + return false; + } + + if (!Ticket::getById((int) $data['ticket_id'])) { + App::getInstance(true)->getLogger()->error('Invalid ticket ID for data export: ' . $data['ticket_id']); + + return false; + } + + $insert = [ + 'uuid' => $data['uuid'], + 'user_uuid' => $data['user_uuid'], + 'ticket_id' => (int) $data['ticket_id'], + 'status' => $data['status'] ?? 'pending', + ]; + + $pdo = Database::getPdoConnection(); + $fields = array_keys($insert); + $fieldList = '`' . implode('`, `', $fields) . '`'; + $placeholders = ':' . implode(', :', $fields); + $stmt = $pdo->prepare('INSERT INTO ' . self::$table . ' (' . $fieldList . ') VALUES (' . $placeholders . ')'); + + if ($stmt->execute($insert)) { + return (int) $pdo->lastInsertId(); + } + + return false; + } + + /** + * Generate a cryptographically secure version 4 UUID. + */ + public static function generateUuid(): string + { + $bytes = random_bytes(16); + $bytes[6] = chr(ord($bytes[6]) & 0x0F | 0x40); + $bytes[8] = chr(ord($bytes[8]) & 0x3F | 0x80); + $hex = bin2hex($bytes); + + return sprintf( + '%s-%s-%s-%s-%s', + substr($hex, 0, 8), + substr($hex, 8, 4), + substr($hex, 12, 4), + substr($hex, 16, 4), + substr($hex, 20, 12) + ); + } + + /** + * Get an export request by ID. + */ + public static function getById(int $id): ?array + { + if ($id <= 0) { + return null; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE id = :id LIMIT 1'); + $stmt->execute(['id' => $id]); + + return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + } + + /** + * Check whether a user already requested an export in the cooldown window. + */ + public static function hasRecentRequestForUser(string $userUuid, int $hours = 24): bool + { + if (!preg_match('/^[a-f0-9\-]{36}$/i', $userUuid) || $hours < 1) { + return false; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare( + 'SELECT COUNT(*) FROM ' . self::$table . ' + WHERE user_uuid = :user_uuid + AND requested_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL :hours HOUR)' + ); + $stmt->bindValue('user_uuid', $userUuid); + $stmt->bindValue('hours', $hours, \PDO::PARAM_INT); + $stmt->execute(); + + return (int) $stmt->fetchColumn() > 0; + } + + /** + * Atomically claim the next pending or retryable failed export. + */ + public static function claimNextPending(int $maxAttempts = 3): ?array + { + $pdo = Database::getPdoConnection(); + $pdo->beginTransaction(); + + try { + $stmt = $pdo->prepare( + 'SELECT * FROM ' . self::$table . ' + WHERE (status = "pending" OR (status = "failed" AND attempts < :max_attempts)) + ORDER BY requested_at ASC, id ASC + LIMIT 1 + FOR UPDATE' + ); + $stmt->bindValue('max_attempts', $maxAttempts, \PDO::PARAM_INT); + $stmt->execute(); + $export = $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + + if ($export === null) { + $pdo->commit(); + + return null; + } + + $update = $pdo->prepare( + 'UPDATE ' . self::$table . ' + SET status = "processing", + attempts = attempts + 1, + processing_started_at = UTC_TIMESTAMP(), + error_message = NULL + WHERE id = :id' + ); + $update->execute(['id' => (int) $export['id']]); + + $pdo->commit(); + + return self::getById((int) $export['id']); + } catch (\Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + App::getInstance(true)->getLogger()->error('Failed to claim user data export: ' . $e->getMessage()); + + return null; + } + } + + /** + * Mark an export as completed. + */ + public static function markCompleted(int $id, string $filePath): bool + { + if ($id <= 0 || trim($filePath) === '') { + return false; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare( + 'UPDATE ' . self::$table . ' + SET status = "completed", + file_path = :file_path, + error_message = NULL, + processed_at = UTC_TIMESTAMP() + WHERE id = :id' + ); + + return $stmt->execute([ + 'id' => $id, + 'file_path' => $filePath, + ]); + } + + /** + * Mark an export as failed. + */ + public static function markFailed(int $id, string $errorMessage): bool + { + if ($id <= 0) { + return false; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare( + 'UPDATE ' . self::$table . ' + SET status = "failed", + error_message = :error_message, + processed_at = UTC_TIMESTAMP() + WHERE id = :id' + ); + + return $stmt->execute([ + 'id' => $id, + 'error_message' => substr($errorMessage, 0, 2000), + ]); + } + + /** + * Get completed or failed exports that are past their retention window. + */ + public static function getExpiredForCleanup(int $hours = 24, int $limit = 25): array + { + if ($hours < 1 || $limit < 1) { + return []; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare( + 'SELECT * FROM ' . self::$table . ' + WHERE status IN ("completed", "failed") + AND COALESCE(processed_at, requested_at) < DATE_SUB(UTC_TIMESTAMP(), INTERVAL :hours HOUR) + ORDER BY COALESCE(processed_at, requested_at) ASC, id ASC + LIMIT :limit' + ); + $stmt->bindValue('hours', $hours, \PDO::PARAM_INT); + $stmt->bindValue('limit', $limit, \PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Delete an export queue row. + */ + public static function deleteById(int $id): bool + { + if ($id <= 0) { + return false; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare('DELETE FROM ' . self::$table . ' WHERE id = :id'); + + return $stmt->execute(['id' => $id]); + } +} diff --git a/backend/app/Chat/VmInstance.php b/backend/app/Chat/VmInstance.php index 634879377..a3fc1f9c1 100644 --- a/backend/app/Chat/VmInstance.php +++ b/backend/app/Chat/VmInstance.php @@ -335,7 +335,7 @@ public static function create(array $data, ?\PDO $pdo = null): ?array */ public static function update(int $id, array $data): bool { - $allowed = ['hostname', 'notes', 'user_uuid', 'vm_ip_id', 'memory', 'cpus', 'cores', 'disk_gb', 'on_boot', 'suspended', 'backup_limit', 'backup_retention_mode']; + $allowed = ['hostname', 'notes', 'user_uuid', 'vm_ip_id', 'memory', 'cpus', 'cores', 'disk_gb', 'on_boot', 'suspended', 'suspension_reason', 'suspended_at', 'suspended_by_uuid', 'backup_limit', 'backup_retention_mode']; $updates = []; $params = ['id' => $id]; @@ -418,6 +418,18 @@ public static function update(int $id, array $data): bool if ($key === 'suspended') { $updates[] = 'suspended = :suspended'; $params['suspended'] = (int) (bool) $data['suspended']; + + continue; + } + if ($key === 'suspension_reason' || $key === 'suspended_by_uuid') { + $updates[] = $key . ' = :' . $key; + $params[$key] = $data[$key] === null ? null : (is_string($data[$key]) ? trim($data[$key]) : $data[$key]); + + continue; + } + if ($key === 'suspended_at') { + $updates[] = 'suspended_at = :suspended_at'; + $params['suspended_at'] = $data['suspended_at'] === null ? null : $data['suspended_at']; } } diff --git a/backend/app/Config/ConfigInterface.php b/backend/app/Config/ConfigInterface.php index 275af30c2..70874bf87 100755 --- a/backend/app/Config/ConfigInterface.php +++ b/backend/app/Config/ConfigInterface.php @@ -47,6 +47,8 @@ interface ConfigInterface public const APP_THEME_LOCK = 'app_theme_lock'; public const APP_BACKGROUND_TYPE_DEFAULT = 'app_background_type_default'; public const APP_BACKGROUND_TYPE_LOCK = 'app_background_type_lock'; + /** When false, hide FeatherPanel "powered by" branding across the panel (typically via White Label addon). */ + public const BRANDING_SHOW_POWERED_BY = 'branding_show_powered_by'; public const APP_BACKDROP_BLUR_DEFAULT = 'app_backdrop_blur_default'; public const APP_BACKDROP_BLUR_LOCK = 'app_backdrop_blur_lock'; public const APP_BACKDROP_DARKEN_DEFAULT = 'app_backdrop_darken_default'; @@ -202,6 +204,15 @@ interface ConfigInterface /** When false, lifecycle hook UI and execution are disabled (default off until enabled by an administrator). */ public const SERVER_LIFECYCLE_HOOKS_ENABLED = 'server_lifecycle_hooks_enabled'; + /** + * File trash bin (soft-delete via FeatherWings). + */ + public const FILE_TRASH_ENABLED = 'file_trash_enabled'; + /** Maximum total size of trashed files per server, in megabytes (0 = unlimited). */ + public const FILE_TRASH_MAX_SIZE_MB = 'file_trash_max_size_mb'; + /** Automatically purge trashed files older than this many days (0 = never by age). */ + public const FILE_TRASH_RETENTION_DAYS = 'file_trash_retention_days'; + /** * User Related Configs. */ diff --git a/backend/app/Config/PublicConfig.php b/backend/app/Config/PublicConfig.php index b033f78a6..8a8c1fb4f 100755 --- a/backend/app/Config/PublicConfig.php +++ b/backend/app/Config/PublicConfig.php @@ -66,6 +66,9 @@ public static function getPublicSettingsWithDefaults(): array // background type: aurora, gradient, solid, image, pattern ConfigInterface::APP_BACKGROUND_TYPE_DEFAULT => 'pattern', ConfigInterface::APP_BACKGROUND_TYPE_LOCK => 'false', + + ConfigInterface::BRANDING_SHOW_POWERED_BY => 'true', + // backdrop blur/darken and image fit defaults + locks ConfigInterface::APP_BACKDROP_BLUR_DEFAULT => '0', ConfigInterface::APP_BACKDROP_BLUR_LOCK => 'false', @@ -163,6 +166,9 @@ public static function getPublicSettingsWithDefaults(): array ConfigInterface::SERVER_ALLOW_USER_MADE_FASTDL => 'false', ConfigInterface::SERVER_ALLOW_USER_MADE_SUBDOMAINS => 'false', ConfigInterface::SERVER_HIDE_IPS => 'false', + ConfigInterface::FILE_TRASH_ENABLED => 'false', + ConfigInterface::FILE_TRASH_MAX_SIZE_MB => '512', + ConfigInterface::FILE_TRASH_RETENTION_DAYS => '30', // User related settings ConfigInterface::USER_ALLOW_AVATAR_CHANGE => 'true', diff --git a/backend/app/Controllers/Admin/AllocationsController.php b/backend/app/Controllers/Admin/AllocationsController.php index 2345ea193..e953c7656 100755 --- a/backend/app/Controllers/Admin/AllocationsController.php +++ b/backend/app/Controllers/Admin/AllocationsController.php @@ -908,6 +908,167 @@ public function getAvailable(Request $request): Response ], 'Available allocations fetched successfully', 200); } + #[OA\Patch( + path: '/api/admin/allocations/bulk-address', + summary: 'Bulk update allocation IP or alias', + description: 'Update the IP address and/or IP alias for every allocation on a node that currently uses a specific IP address.', + tags: ['Admin - Allocations'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['node_id', 'from_ip'], + properties: [ + new OA\Property(property: 'node_id', type: 'integer', description: 'Node ID containing the allocations', minimum: 1), + new OA\Property(property: 'from_ip', type: 'string', description: 'Current allocation IP address', example: '0.0.0.0'), + new OA\Property(property: 'to_ip', type: 'string', nullable: true, description: 'New allocation IP address', example: '6.6.6.6'), + new OA\Property(property: 'ip_alias', type: 'string', nullable: true, description: 'New IP alias. Send null or an empty string to clear it.'), + ] + ) + ), + responses: [ + new OA\Response(response: 200, description: 'Allocations updated successfully'), + new OA\Response(response: 400, description: 'Bad request - Invalid data'), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden - Insufficient permissions'), + new OA\Response(response: 404, description: 'Node or matching allocations not found'), + new OA\Response(response: 409, description: 'Target IP has ports already assigned to another server'), + ] + )] + public function bulkUpdateAddress(Request $request): Response + { + $admin = $request->attributes->get('user'); + $data = json_decode($request->getContent(), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return ApiResponse::error('Invalid JSON in request body', 'INVALID_JSON', 400); + } + + if (!is_array($data)) { + return ApiResponse::error('Request body must be a JSON object', 'INVALID_JSON_OBJECT', 400); + } + + $allowedFields = ['node_id', 'from_ip', 'to_ip', 'ip_alias']; + $invalidFields = array_diff(array_keys($data), $allowedFields); + if (!empty($invalidFields)) { + return ApiResponse::error( + 'Invalid fields provided: ' . implode(', ', $invalidFields) . '. Allowed fields: ' . implode(', ', $allowedFields), + 'INVALID_FIELDS', + 400 + ); + } + + $missingFields = []; + foreach (['node_id', 'from_ip'] as $field) { + if (!isset($data[$field]) || trim((string) $data[$field]) === '') { + $missingFields[] = $field; + } + } + + if (!empty($missingFields)) { + return ApiResponse::error('Missing required fields: ' . implode(', ', $missingFields), 'MISSING_REQUIRED_FIELDS', 400); + } + + if (!is_numeric($data['node_id']) || (int) $data['node_id'] <= 0) { + return ApiResponse::error('Node ID must be a positive number', 'INVALID_NODE_ID', 400); + } + + $nodeId = (int) $data['node_id']; + if (!Node::getNodeById($nodeId)) { + return ApiResponse::error('Node not found', 'NODE_NOT_FOUND', 404); + } + + $fromIp = trim((string) $data['from_ip']); + if (!filter_var($fromIp, FILTER_VALIDATE_IP)) { + return ApiResponse::error('Invalid source IP address format', 'INVALID_FROM_IP_FORMAT', 400); + } + + $toIp = null; + if (array_key_exists('to_ip', $data) && trim((string) $data['to_ip']) !== '') { + $toIp = trim((string) $data['to_ip']); + if (!filter_var($toIp, FILTER_VALIDATE_IP)) { + return ApiResponse::error('Invalid target IP address format', 'INVALID_TO_IP_FORMAT', 400); + } + } + + $updateAlias = array_key_exists('ip_alias', $data); + $ipAlias = null; + if ($updateAlias) { + if ($data['ip_alias'] !== null && !is_string($data['ip_alias'])) { + return ApiResponse::error('IP alias must be a string or null', 'INVALID_IP_ALIAS', 400); + } + $ipAlias = isset($data['ip_alias']) ? trim($data['ip_alias']) : null; + if ($ipAlias === '') { + $ipAlias = null; + } + } + + if ($toIp === null && !$updateAlias) { + return ApiResponse::error('Provide a target IP address, an IP alias, or both', 'NO_CHANGES_PROVIDED', 400); + } + + $matchedCount = Allocation::countByNodeAndIp($nodeId, $fromIp); + if ($matchedCount === 0) { + return ApiResponse::error('No allocations found for this node and IP address', 'ALLOCATIONS_NOT_FOUND', 404); + } + + $result = Allocation::updateAddressByNodeAndIp($nodeId, $fromIp, $toIp, $ipAlias, $updateAlias); + if ($result === false) { + return ApiResponse::error('Failed to update allocations', 'ALLOCATION_UPDATE_FAILED', 400); + } + + if (($result['assigned_conflict_count'] ?? 0) > 0) { + return ApiResponse::error( + "Cannot merge {$result['assigned_conflict_count']} port(s) because both the source and target allocation are assigned to servers", + 'ASSIGNED_IP_PORT_CONFLICT', + 409 + ); + } + + $updatedCount = (int) ($result['updated_count'] ?? 0); + $deletedTargetConflicts = (int) ($result['deleted_target_conflicts'] ?? 0); + $deletedSourceConflicts = (int) ($result['deleted_source_conflicts'] ?? 0); + + $changes = []; + if ($toIp !== null) { + $changes[] = "IP {$fromIp} to {$toIp}"; + } + if ($updateAlias) { + $changes[] = $ipAlias === null ? 'cleared IP alias' : "IP alias to {$ipAlias}"; + } + + Activity::createActivity([ + 'user_uuid' => $admin['uuid'] ?? null, + 'name' => 'allocations_address_updated', + 'context' => 'Updated ' . implode(' and ', $changes) . " for {$matchedCount} allocation(s) on node ID {$nodeId}. Merged {$deletedTargetConflicts} target conflict(s) and {$deletedSourceConflicts} source conflict(s).", + 'ip_address' => CloudFlareRealIP::getRealIP(), + ]); + + global $eventManager; + if (isset($eventManager) && $eventManager !== null) { + $eventManager->emit( + AllocationsEvent::onAllocationUpdated(), + [ + 'node_id' => $nodeId, + 'from_ip' => $fromIp, + 'to_ip' => $toIp, + 'ip_alias' => $ipAlias, + 'matched_count' => $matchedCount, + 'updated_count' => $updatedCount, + 'deleted_target_conflicts' => $deletedTargetConflicts, + 'deleted_source_conflicts' => $deletedSourceConflicts, + 'updated_by' => $admin, + ] + ); + } + + return ApiResponse::success([ + 'matched_count' => $matchedCount, + 'updated_count' => $updatedCount, + 'deleted_target_conflicts' => $deletedTargetConflicts, + 'deleted_source_conflicts' => $deletedSourceConflicts, + ], "Updated {$matchedCount} allocation(s)", 200); + } + #[OA\Delete( path: '/api/admin/allocations/bulk-delete', summary: 'Bulk delete allocations', diff --git a/backend/app/Controllers/Admin/CloudManagementController.php b/backend/app/Controllers/Admin/CloudManagementController.php index ba9096500..64a95b5e8 100755 --- a/backend/app/Controllers/Admin/CloudManagementController.php +++ b/backend/app/Controllers/Admin/CloudManagementController.php @@ -302,15 +302,16 @@ public function rotate(Request $request): Response $privateKey = 'FCPRIV-' . base64_encode(random_bytes(48)); $timestamp = gmdate('c'); - // Rotate FeatherCloud → Panel keys - $config->setSetting(ConfigInterface::FEATHERCLOUD_ACCESS_PUBLIC_KEY, $publicKey); - $config->setSetting(ConfigInterface::FEATHERCLOUD_ACCESS_PRIVATE_KEY, $privateKey); - $config->setSetting(ConfigInterface::FEATHERCLOUD_ACCESS_LAST_ROTATED, $timestamp); + // Panel identity keys used during OAuth (`public_identity_key` / `private_key`). + $config->setSetting(ConfigInterface::FEATHERCLOUD_CLOUD_PUBLIC_KEY, $publicKey); + $config->setSetting(ConfigInterface::FEATHERCLOUD_CLOUD_PRIVATE_KEY, $privateKey); + $config->setSetting(ConfigInterface::FEATHERCLOUD_CLOUD_LAST_ROTATED, $timestamp); - // Clear Panel → FeatherCloud keys (they need to be regenerated) - $config->setSetting(ConfigInterface::FEATHERCLOUD_CLOUD_PUBLIC_KEY, null); - $config->setSetting(ConfigInterface::FEATHERCLOUD_CLOUD_PRIVATE_KEY, null); - $config->setSetting(ConfigInterface::FEATHERCLOUD_CLOUD_LAST_ROTATED, null); + // FeatherCloud-issued API keys must come from OAuth; never substitute random values here + // or api.featherpanel.com responds with "Invalid panel public key". + $config->setSetting(ConfigInterface::FEATHERCLOUD_ACCESS_PUBLIC_KEY, null); + $config->setSetting(ConfigInterface::FEATHERCLOUD_ACCESS_PRIVATE_KEY, null); + $config->setSetting(ConfigInterface::FEATHERCLOUD_ACCESS_LAST_ROTATED, null); $user = $request->attributes->get('user'); $userUuid = $user['uuid'] ?? null; @@ -318,7 +319,7 @@ public function rotate(Request $request): Response Activity::createActivity([ 'user_uuid' => $userUuid, 'name' => 'rotate_cloud_credentials', - 'context' => 'FeatherCloud → Panel credentials were rotated, Panel → FeatherCloud keys cleared', + 'context' => 'Panel FeatherCloud identity keys rotated; FeatherCloud-issued credentials cleared - OAuth link required again', 'ip_address' => CloudFlareRealIP::getRealIP(), ]); diff --git a/backend/app/Controllers/Admin/CloudPluginsController.php b/backend/app/Controllers/Admin/CloudPluginsController.php index 33120550b..df8ebe29e 100755 --- a/backend/app/Controllers/Admin/CloudPluginsController.php +++ b/backend/app/Controllers/Admin/CloudPluginsController.php @@ -1521,9 +1521,12 @@ private static function normalizePackageForResponse(array $pkg, ?array $latestOv } } + $premiumLink = isset($pkg['premium_link']) && is_string($pkg['premium_link']) ? $pkg['premium_link'] : null; + return [ 'id' => $pkg['id'] ?? null, 'identifier' => $pkg['name'] ?? '', + 'store_slug' => self::extractStoreSlugFromPremiumLink($premiumLink), 'name' => $pkg['display_name'] ?? ($pkg['name'] ?? ''), 'description' => $pkg['description'] ?? null, 'icon' => PanelAssetUrl::rewriteCloudStorageIcon(is_string($iconUrl) ? $iconUrl : null), @@ -1534,7 +1537,7 @@ private static function normalizePackageForResponse(array $pkg, ?array $latestOv 'tags' => $pkg['tags'] ?? [], 'verified' => isset($pkg['verified']) ? (int) $pkg['verified'] === 1 : false, 'premium' => isset($pkg['premium']) ? (int) $pkg['premium'] : 0, - 'premium_link' => $pkg['premium_link'] ?? null, + 'premium_link' => $premiumLink, 'premium_price' => $pkg['premium_price'] ?? null, 'downloads' => $pkg['downloads'] ?? 0, 'created_at' => $pkg['created_at'] ?? null, @@ -1543,6 +1546,25 @@ private static function normalizePackageForResponse(array $pkg, ?array $latestOv ]; } + /** + * Marketplace slug from the premium purchase URL (last path segment). + */ + private static function extractStoreSlugFromPremiumLink(?string $link): ?string + { + if ($link === null || trim($link) === '') { + return null; + } + + $path = parse_url($link, PHP_URL_PATH); + if (!is_string($path) || $path === '') { + return null; + } + + $segments = array_values(array_filter(explode('/', $path), static fn (string $s): bool => $s !== '')); + + return $segments !== [] ? (string) end($segments) : null; + } + /** * Execute addon-provided SQL migrations from the addon's Migrations directory. * Each script will be recorded in featherpanel_migrations with a unique key diff --git a/backend/app/Controllers/Admin/NodesController.php b/backend/app/Controllers/Admin/NodesController.php index 38ab02275..168d2e26c 100755 --- a/backend/app/Controllers/Admin/NodesController.php +++ b/backend/app/Controllers/Admin/NodesController.php @@ -20,9 +20,12 @@ use App\App; use App\Chat\Node; use App\Cache\Cache; +use App\Chat\Server; use App\Chat\Activity; use App\Chat\Location; use GuzzleHttp\Client; +use App\Chat\Allocation; +use App\Chat\ServerTransfer; use App\Helpers\ApiResponse; use App\Services\Wings\Wings; use OpenApi\Attributes as OA; @@ -31,6 +34,7 @@ use App\Plugins\Events\Events\NodesEvent; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use App\Services\Servers\ServerTransferInitiator; #[OA\Schema( schema: 'Node', @@ -604,7 +608,7 @@ public function delete(Request $request, int $id): Response return ApiResponse::error('Node not found', 'NODE_NOT_FOUND', 404); } // Check if the node has any servers assigned before allowing deletion - $serversCount = \App\Chat\Server::count(['node_id' => $id]); + $serversCount = Server::count(['node_id' => $id]); if ($serversCount > 0) { return ApiResponse::error('Cannot delete node: there are servers assigned to this node. Please remove or reassign all servers before deleting the node.', 'NODE_HAS_SERVERS', 400); } @@ -1632,4 +1636,179 @@ public function getVersionStatus(Request $request, int $id): Response return ApiResponse::error('Failed to check version status', 'VERSION_CHECK_FAILED', 500); } } + + #[OA\Get( + path: '/api/admin/nodes/{id}/mass-transfer/preview', + summary: 'Preview mass server transfer from a node', + description: 'Lists transferable servers on the source node and free allocation capacity on a destination node.', + tags: ['Admin - Nodes'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', description: 'Source node ID', required: true, schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'destination_node_id', in: 'query', description: 'Destination node ID', required: true, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response(response: 200, description: 'Preview retrieved successfully'), + new OA\Response(response: 400, description: 'Bad request'), + new OA\Response(response: 404, description: 'Node not found'), + ] + )] + public function previewMassTransfer(Request $request, int $id): Response + { + $sourceNode = Node::getNodeById($id); + if (!$sourceNode) { + return ApiResponse::error('Source node not found', 'NODE_NOT_FOUND', 404); + } + + $destinationNodeId = (int) $request->query->get('destination_node_id', 0); + if ($destinationNodeId <= 0) { + return ApiResponse::error('Invalid or missing destination_node_id', 'INVALID_DESTINATION_NODE', 400); + } + + if ($id === $destinationNodeId) { + return ApiResponse::error('Cannot transfer to the same node', 'SAME_SOURCE_DESTINATION', 400); + } + + $destinationNode = Node::getNodeById($destinationNodeId); + if (!$destinationNode) { + return ApiResponse::error('Destination node not found', 'DESTINATION_NODE_NOT_FOUND', 404); + } + + $servers = Server::getServersByNodeId($id); + $transferable = []; + $skipped = []; + $allocationsRequired = 0; + + foreach ($servers as $server) { + $serverId = (int) $server['id']; + if ($server['status'] === 'transferring' || ServerTransfer::hasActiveTransfer($serverId)) { + $skipped[] = [ + 'id' => $serverId, + 'name' => $server['name'], + 'reason' => 'already_transferring', + ]; + continue; + } + if ($server['status'] === 'installing' || $server['status'] === 'restoring') { + $skipped[] = [ + 'id' => $serverId, + 'name' => $server['name'], + 'reason' => 'not_transferable', + ]; + continue; + } + + $allocationCount = count(Allocation::getByServerId($serverId)); + $needed = max(1, $allocationCount); + $allocationsRequired += $needed; + + $transferable[] = [ + 'id' => $serverId, + 'name' => $server['name'], + 'uuid' => $server['uuid'], + 'status' => $server['status'], + 'allocations_needed' => $needed, + ]; + } + + $freeOnDestination = Allocation::getFreeCountByNodeId($destinationNodeId); + + return ApiResponse::success([ + 'source_node' => ['id' => $id, 'name' => $sourceNode['name']], + 'destination_node' => ['id' => $destinationNodeId, 'name' => $destinationNode['name']], + 'transferable_servers' => $transferable, + 'skipped_servers' => $skipped, + 'allocations_required' => $allocationsRequired, + 'free_allocations_on_destination' => $freeOnDestination, + 'has_enough_allocations' => $freeOnDestination >= $allocationsRequired, + 'max_per_request' => 100, + ], 'Mass transfer preview', 200); + } + + #[OA\Post( + path: '/api/admin/nodes/{id}/mass-transfer', + summary: 'Mass transfer servers from a node', + description: 'Transfer selected servers (or all transferable servers) from this node to another. Free allocations on the destination node are assigned automatically.', + tags: ['Admin - Nodes'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', description: 'Source node ID', required: true, schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['destination_node_id'], + properties: [ + new OA\Property(property: 'destination_node_id', type: 'integer', minimum: 1), + new OA\Property(property: 'server_ids', type: 'array', items: new OA\Items(type: 'integer'), description: 'Server IDs to move (ignored when move_all is true)'), + new OA\Property(property: 'move_all', type: 'boolean', description: 'Move every transferable server on this node'), + ] + ) + ), + responses: [ + new OA\Response(response: 200, description: 'Mass transfer processed'), + new OA\Response(response: 400, description: 'Bad request'), + new OA\Response(response: 404, description: 'Node not found'), + ] + )] + public function massTransfer(Request $request, int $id): Response + { + $sourceNode = Node::getNodeById($id); + if (!$sourceNode) { + return ApiResponse::error('Source node not found', 'NODE_NOT_FOUND', 404); + } + + $data = json_decode($request->getContent(), true); + if (json_last_error() !== JSON_ERROR_NONE) { + return ApiResponse::error('Invalid JSON in request body', 'INVALID_JSON', 400); + } + + if (!isset($data['destination_node_id']) || !is_numeric($data['destination_node_id'])) { + return ApiResponse::error('Invalid or missing destination_node_id', 'INVALID_DESTINATION_NODE', 400); + } + + $destinationNodeId = (int) $data['destination_node_id']; + $moveAll = !empty($data['move_all']); + $serverIds = isset($data['server_ids']) && is_array($data['server_ids']) + ? array_map('intval', $data['server_ids']) + : []; + + if (!$moveAll && empty($serverIds)) { + return ApiResponse::error('Provide server_ids or set move_all to true', 'NO_SERVERS_SELECTED', 400); + } + + if ($id === $destinationNodeId) { + return ApiResponse::error('Cannot transfer to the same node', 'SAME_SOURCE_DESTINATION', 400); + } + + if (!Node::getNodeById($destinationNodeId)) { + return ApiResponse::error('Destination node not found', 'DESTINATION_NODE_NOT_FOUND', 404); + } + + $initiator = new ServerTransferInitiator(); + $results = $initiator->massTransfer( + $id, + $destinationNodeId, + $serverIds, + $moveAll, + $request->attributes->get('user') + ); + + Activity::createActivity([ + 'user_uuid' => $request->attributes->get('user')['uuid'], + 'name' => 'mass_transfer_servers', + 'context' => 'Mass transfer from node ' . $sourceNode['name'] . ': ' + . count($results['initiated']) . ' initiated, ' + . count($results['failed']) . ' failed, ' + . count($results['skipped']) . ' skipped', + 'ip_address' => CloudFlareRealIP::getRealIP(), + ]); + + return ApiResponse::success([ + 'initiated' => $results['initiated'], + 'failed' => $results['failed'], + 'skipped' => $results['skipped'], + 'initiated_count' => count($results['initiated']), + 'failed_count' => count($results['failed']), + 'skipped_count' => count($results['skipped']), + ], 'Mass transfer processed', 200); + } } diff --git a/backend/app/Controllers/Admin/PluginManagerController.php b/backend/app/Controllers/Admin/PluginManagerController.php index 142922684..1dfe7eb27 100755 --- a/backend/app/Controllers/Admin/PluginManagerController.php +++ b/backend/app/Controllers/Admin/PluginManagerController.php @@ -1719,7 +1719,8 @@ public function run() 'component' => 'serverui.html', 'description' => 'View server logs related to the plugin', 'category' => 'server', - 'group' => 'Minecraft Java Edition', + 'group' => 'files', + 'priority' => 350, ], ], ], JSON_PRETTY_PRINT); @@ -2303,6 +2304,7 @@ public static function getSubCommands(): array ### Server Section - **Server Logs**: View plugin-related logs - **Scheduled Tasks**: Manage cron jobs and tasks +- **Sidebar Priority**: Use `group: files` and `priority: 350` to place an item between Backups (300) and Import (400) ### Routes & Controllers - **Routes**: Files in `Routes/` directory are automatically registered diff --git a/backend/app/Controllers/Admin/ServersController.php b/backend/app/Controllers/Admin/ServersController.php index effa943db..b12a3f349 100755 --- a/backend/app/Controllers/Admin/ServersController.php +++ b/backend/app/Controllers/Admin/ServersController.php @@ -29,6 +29,7 @@ use App\Chat\Allocation; use App\Helpers\UUIDUtils; use App\Chat\SpellVariable; +use App\Helpers\TimeHelper; use App\Chat\ServerActivity; use App\Chat\ServerDatabase; use App\Chat\ServerTransfer; @@ -38,11 +39,13 @@ use OpenApi\Attributes as OA; use App\Chat\DatabaseInstance; use App\Config\ConfigInterface; +use App\Chat\ServerCustomVariable; use App\CloudFlare\CloudFlareRealIP; use App\Mail\templates\ServerBanned; use App\Mail\templates\ServerCreated; use App\Mail\templates\ServerDeleted; use App\Mail\templates\ServerUnbanned; +use App\Helpers\ModerationReasonHelper; use App\Plugins\Events\Events\ServerEvent; use App\Services\Backup\BackupFifoEviction; use Symfony\Component\HttpFoundation\Request; @@ -386,6 +389,7 @@ public function index(Request $request): Response // Remove sensitive data from owner if ($server['owner']) { unset($server['owner']['password'], $server['owner']['remember_token'], $server['owner']['two_fa_key']); + $server['owner'] = TimeHelper::normaliseRow($server['owner'], ['last_seen', 'first_seen']); } // Remove sensitive node data @@ -403,6 +407,8 @@ public function index(Request $request): Response $server['node']['daemonBase'] ); } + + $server = TimeHelper::normaliseRow($server, ['installed_at']); } $total = Server::getCount( @@ -539,6 +545,7 @@ public function show(Request $request, int $id): Response } $server['variables'] = $mergedVariables; + $server['custom_variables'] = ServerCustomVariable::getCustomVariablesByServerId((int) $server['id']); $attachedMounts = Mount::getMountsAttachedToServer((int) $server['id']); $server['mounts'] = $attachedMounts; @@ -558,6 +565,7 @@ public function show(Request $request, int $id): Response // Remove sensitive data from related objects if ($server['owner']) { unset($server['owner']['password'], $server['owner']['remember_token'], $server['owner']['two_fa_key']); + $server['owner'] = TimeHelper::normaliseRow($server['owner'], ['last_seen', 'first_seen']); } // Remove sensitive node data @@ -574,9 +582,116 @@ public function show(Request $request, int $id): Response $server['node']['daemonBase'] ); + $server = ModerationReasonHelper::enrichServerSuspensionMetadata($server); + $server = TimeHelper::normaliseRow($server, ['installed_at', 'suspended_at']); + return ApiResponse::success($server, 'Server fetched successfully', 200); } + public function createCustomVariable(Request $request, int $id): Response + { + $server = Server::getServerById($id); + if (!$server) { + return ApiResponse::error('Server not found', 'SERVER_NOT_FOUND', 404); + } + + $data = json_decode($request->getContent(), true); + if (!is_array($data)) { + return ApiResponse::error('Invalid request data', 'INVALID_REQUEST', 400); + } + + $name = trim((string) ($data['name'] ?? '')); + $envVariable = strtoupper(trim((string) ($data['env_variable'] ?? ''))); + $value = (string) ($data['variable_value'] ?? ''); + $isEncrypted = isset($data['is_encrypted']) && filter_var($data['is_encrypted'], FILTER_VALIDATE_BOOLEAN); + + if ($name === '' || strlen($name) > 191) { + return ApiResponse::error('Variable name is required and must be less than 191 characters', 'INVALID_NAME', 400); + } + + if (!ServerCustomVariable::isValidEnvVariable($envVariable) || strlen($envVariable) > 191) { + return ApiResponse::error('Environment variable must use only uppercase letters, numbers, and underscores, and cannot start with a number', 'INVALID_ENV_VARIABLE', 400); + } + + $reservedVariables = [ + 'P_SERVER_LOCATION', + 'P_SERVER_UUID', + 'P_SERVER_ALLOCATION_LIMIT', + 'SERVER_MEMORY', + 'SERVER_IP', + 'SERVER_PORT', + ]; + if (in_array($envVariable, $reservedVariables, true) || str_starts_with($envVariable, 'P_SERVER_')) { + return ApiResponse::error('This environment variable name is reserved', 'RESERVED_ENV_VARIABLE', 400); + } + + if (ServerCustomVariable::envVariableExists((int) $server['id'], $envVariable)) { + return ApiResponse::error('An environment variable with this name already exists', 'ENV_VARIABLE_EXISTS', 409); + } + + $user = $request->attributes->get('user'); + $variableId = ServerCustomVariable::createCustomVariable([ + 'server_id' => (int) $server['id'], + 'user_id' => (int) ($user['id'] ?? $server['owner_id']), + 'name' => $name, + 'env_variable' => $envVariable, + 'variable_value' => $value, + 'is_encrypted' => $isEncrypted ? 1 : 0, + ]); + + if (!$variableId) { + return ApiResponse::error('Failed to create custom variable', 'CUSTOM_VARIABLE_CREATE_FAILED', 500); + } + + $syncError = $this->syncServerConfiguration($server); + if ($syncError !== null) { + return $syncError; + } + + Activity::createActivity([ + 'user_uuid' => $user['uuid'] ?? null, + 'name' => 'create_server_custom_variable', + 'context' => 'Created custom variable ' . $envVariable . ' for server ' . $server['name'], + 'ip_address' => CloudFlareRealIP::getRealIP(), + ]); + + return ApiResponse::success([ + 'custom_variable' => ServerCustomVariable::getCustomVariableById((int) $variableId), + ], 'Custom variable created successfully', 201); + } + + public function deleteCustomVariable(Request $request, int $id, int $variableId): Response + { + $server = Server::getServerById($id); + if (!$server) { + return ApiResponse::error('Server not found', 'SERVER_NOT_FOUND', 404); + } + + $customVariable = ServerCustomVariable::getCustomVariableById($variableId); + if (!$customVariable || (int) $customVariable['server_id'] !== (int) $server['id']) { + return ApiResponse::error('Custom variable not found', 'CUSTOM_VARIABLE_NOT_FOUND', 404); + } + + if (!ServerCustomVariable::deleteCustomVariableForServer($variableId, (int) $server['id'])) { + return ApiResponse::error('Failed to delete custom variable', 'CUSTOM_VARIABLE_DELETE_FAILED', 500); + } + + $syncError = $this->syncServerConfiguration($server); + if ($syncError !== null) { + return $syncError; + } + + $user = $request->attributes->get('user'); + Activity::createActivity([ + 'user_uuid' => $user['uuid'] ?? null, + 'name' => 'delete_server_custom_variable', + 'context' => 'Deleted custom variable ' . $customVariable['env_variable'] . ' from server ' . $server['name'], + 'ip_address' => CloudFlareRealIP::getRealIP(), + ]); + + return ApiResponse::success([], 'Custom variable deleted successfully', 200); + } + #[OA\Get( path: '/api/admin/servers/external/{externalId}', summary: 'Get server by external ID', @@ -715,6 +830,9 @@ public function showByExternalId(Request $request, string $externalId): Response $server['node']['daemonBase'] ); + $server = ModerationReasonHelper::enrichServerSuspensionMetadata($server); + $server = TimeHelper::normaliseRow($server, ['installed_at', 'suspended_at']); + return ApiResponse::success($server, 'Server fetched successfully', 200); } @@ -2403,7 +2521,18 @@ public function suspend(Request $request, int $id): Response return ApiResponse::error('Server not found', 'SERVER_NOT_FOUND', 404); } - $ok = Server::updateServerById($id, ['suspended' => 1]); + $body = json_decode($request->getContent(), true); + if (!is_array($body)) { + $body = []; + } + $parsed = ModerationReasonHelper::parseRequestBody($body); + $reasonError = ModerationReasonHelper::validateReason($parsed['reason']); + if ($reasonError !== null) { + return ApiResponse::error($reasonError, 'MODERATION_REASON_REQUIRED', 400); + } + + $staffUser = $request->attributes->get('user'); + $ok = Server::updateServerById($id, ModerationReasonHelper::suspensionAppliedFields($parsed['reason'], $staffUser)); if (!$ok) { return ApiResponse::error('Failed to suspend server', 'FAILED_TO_SUSPEND', 500); } @@ -2411,9 +2540,9 @@ public function suspend(Request $request, int $id): Response $user = User::getUserById($server['owner_id']); Activity::createActivity([ - 'user_uuid' => $request->attributes->get('user')['uuid'], + 'user_uuid' => $staffUser['uuid'], 'name' => 'suspend_server', - 'context' => 'Suspended server ' . $server['name'], + 'context' => 'Suspended server ' . $server['name'] . ' — ' . $parsed['reason'], 'ip_address' => CloudFlareRealIP::getRealIP(), ]); @@ -2479,6 +2608,7 @@ public function suspend(Request $request, int $id): Response 'uuid' => $user['uuid'], 'enabled' => $config->getSetting(ConfigInterface::SMTP_ENABLED, 'false'), 'server_name' => $server['name'], + 'suspension_reason' => $parsed['reason'], ]); } catch (\Exception $e) { App::getInstance(true)->getLogger()->error('Failed to send server suspended email: ' . $e->getMessage()); @@ -2525,7 +2655,7 @@ public function unsuspend(Request $request, int $id): Response return ApiResponse::error('Server not found', 'SERVER_NOT_FOUND', 404); } - $ok = Server::updateServerById($id, ['suspended' => 0]); + $ok = Server::updateServerById($id, ModerationReasonHelper::suspensionClearedFields()); if (!$ok) { return ApiResponse::error('Failed to unsuspend server', 'FAILED_TO_UNSUSPEND', 500); } @@ -2617,218 +2747,36 @@ public function unsuspend(Request $request, int $id): Response )] public function initiateTransfer(Request $request, int $id): Response { - $server = Server::getServerById($id); - if (!$server) { - return ApiResponse::error('Server not found', 'SERVER_NOT_FOUND', 404); - } - $data = json_decode($request->getContent(), true); if (json_last_error() !== JSON_ERROR_NONE) { return ApiResponse::error('Invalid JSON in request body', 'INVALID_JSON', 400); } - if (!isset($data['destination_node_id']) || !is_numeric($data['destination_node_id'])) { - return ApiResponse::error('Invalid or missing destination_node_id', 'INVALID_DESTINATION_NODE', 400); - } - - $destinationNodeId = (int) $data['destination_node_id']; - - // Prevent transferring to the same node - if ($server['node_id'] === $destinationNodeId) { - return ApiResponse::error('Cannot transfer server to the same node', 'SAME_SOURCE_DESTINATION', 400); - } - - // Check if server is already transferring or has an active transfer - if ($server['status'] === 'transferring' || ServerTransfer::hasActiveTransfer($id)) { - return ApiResponse::error('Server is already being transferred', 'ALREADY_TRANSFERRING', 400); - } - - // Check if server is in a transferable state (installed, not restoring backup) - if ($server['status'] === 'installing' || $server['status'] === 'restoring') { - return ApiResponse::error('Server cannot be transferred while installing or restoring', 'SERVER_NOT_TRANSFERABLE', 400); - } - - // Get destination node - $destinationNode = Node::getNodeById($destinationNodeId); - if (!$destinationNode) { - return ApiResponse::error('Destination node not found', 'DESTINATION_NODE_NOT_FOUND', 404); + $options = $data; + if (!array_key_exists('auto_allocate', $options)) { + $options['auto_allocate'] = !isset($data['destination_allocation_id']); } - // Get source node for Wings connection - $sourceNode = Node::getNodeById($server['node_id']); - if (!$sourceNode) { - return ApiResponse::error('Source node not found', 'SOURCE_NODE_NOT_FOUND', 404); - } - - // Store original values in case we need to revert - $originalNodeId = $server['node_id']; - $originalAllocationId = $server['allocation_id']; - - // Get server's current allocations (primary + additional) - $currentAllocations = Allocation::getByServerId($id); - $oldAdditionalAllocations = array_filter( - array_column($currentAllocations, 'id'), - fn ($allocId) => $allocId != $originalAllocationId + $result = (new \App\Services\Servers\ServerTransferInitiator())->initiate( + $id, + $options, + $request->attributes->get('user') ); - // Validate and get destination allocation - $newAllocationId = null; - if (isset($data['destination_allocation_id'])) { - $destinationAllocation = Allocation::getAllocationById($data['destination_allocation_id']); - if (!$destinationAllocation) { - return ApiResponse::error('Destination allocation not found', 'DESTINATION_ALLOCATION_NOT_FOUND', 404); - } - if ($destinationAllocation['node_id'] !== $destinationNodeId) { - return ApiResponse::error('Destination allocation does not belong to destination node', 'ALLOCATION_NODE_MISMATCH', 400); - } - if ($destinationAllocation['server_id'] !== null) { - return ApiResponse::error('Destination allocation is already assigned to another server', 'ALLOCATION_IN_USE', 400); - } - $newAllocationId = $destinationAllocation['id']; - } - - // Get additional allocations for destination (optional) - $newAdditionalAllocations = []; - if (isset($data['destination_additional_allocations']) && is_array($data['destination_additional_allocations'])) { - foreach ($data['destination_additional_allocations'] as $allocId) { - $alloc = Allocation::getAllocationById((int) $allocId); - if ($alloc && $alloc['node_id'] === $destinationNodeId && $alloc['server_id'] === null) { - $newAdditionalAllocations[] = (int) $allocId; - } - } - } - - // Generate transfer JWT token (subject is the destination server UUID) - $config = App::getInstance(true)->getConfig(); - $panelUrl = $config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems'); - $destinationUrl = $destinationNode['scheme'] . '://' . $destinationNode['fqdn'] . ':' . $destinationNode['daemonListen']; - - try { - // Temporarily assign new allocations to the server (so they can't be taken during transfer) - if ($newAllocationId) { - $allocationsToAssign = [$newAllocationId]; - if (!empty($newAdditionalAllocations)) { - $allocationsToAssign = array_merge($allocationsToAssign, $newAdditionalAllocations); - } - Allocation::assignMultipleToServer($id, $allocationsToAssign); - } - - // Update server status to transferring AND update node_id to destination - // This allows destination Wings to query Panel for server configuration - $updated = Server::updateServerById($id, [ - 'status' => 'transferring', - 'node_id' => $destinationNodeId, - ]); - if (!$updated) { - // Revert allocation assignment - if ($newAllocationId) { - $allocationsToRevert = [$newAllocationId]; - if (!empty($newAdditionalAllocations)) { - $allocationsToRevert = array_merge($allocationsToRevert, $newAdditionalAllocations); - } - Allocation::unassignMultiple($allocationsToRevert); - } - - return ApiResponse::error('Failed to update server status', 'UPDATE_FAILED', 500); - } - - $wings = new Wings( - $sourceNode['fqdn'], - $sourceNode['daemonListen'], - $sourceNode['scheme'], - $sourceNode['daemon_token'], - 30 - ); - - // Get JWT service and generate transfer token - $jwtService = new \App\Services\Wings\Services\JwtService( - $destinationNode['daemon_token'], - $panelUrl, - $destinationUrl, - 3600 // 1 hour expiration for transfers - ); - - $transferToken = $jwtService->generateTransferToken( - $server['uuid'], - $request->attributes->get('user')['uuid'], - ['*'] // Full permissions for transfer + if (!$result['success']) { + return ApiResponse::error( + $result['error'] ?? 'Transfer failed', + $result['code'] ?? 'TRANSFER_FAILED', + $result['http_status'] ?? 500 ); - - // Prepare transfer request data for source node - // Wings will extract server UUID from JWT's 'sub' claim - $transferData = [ - 'url' => $destinationUrl . '/api/transfers', - 'token' => 'Bearer ' . $transferToken, - ]; - - // Initiate transfer on source node - $response = $wings->getTransfer()->startTransfer($server['uuid'], $transferData); - - // Create transfer record in database with full allocation tracking - $transferId = ServerTransfer::create([ - 'server_id' => $id, - 'source_node_id' => $originalNodeId, - 'destination_node_id' => $destinationNodeId, - 'old_allocation' => $originalAllocationId, - 'new_allocation' => $newAllocationId, - 'old_additional_allocations' => $oldAdditionalAllocations, - 'new_additional_allocations' => $newAdditionalAllocations, - 'status' => 'in_progress', - 'progress' => 0.0, - 'started_at' => date('Y-m-d H:i:s'), - ]); - - if (!$transferId) { - App::getInstance(true)->getLogger()->error('Failed to create server transfer record'); - // Don't fail the transfer if database insert fails, but log it - } - - // Log activity - Activity::createActivity([ - 'user_uuid' => $request->attributes->get('user')['uuid'], - 'name' => 'initiate_server_transfer', - 'context' => 'Initiated transfer of server ' . $server['name'] . ' from node ' . $sourceNode['name'] . ' to node ' . $destinationNode['name'], - 'ip_address' => CloudFlareRealIP::getRealIP(), - ]); - - // Emit event - global $eventManager; - if (isset($eventManager) && $eventManager !== null) { - $eventManager->emit( - ServerEvent::onServerTransferInitiated(), - [ - 'server' => $server, - 'source_node' => $sourceNode, - 'destination_node' => $destinationNode, - 'initiated_by' => $request->attributes->get('user'), - ] - ); - } - - return ApiResponse::success([ - 'message' => 'Server transfer initiated successfully', - 'transfer_id' => $transferId, - ], 'Server transfer initiated', 200); - } catch (\Exception $e) { - // Revert server status AND node_id if transfer initiation fails - Server::updateServerById($id, [ - 'status' => $server['status'], - 'node_id' => $originalNodeId, - ]); - - // Revert allocation assignment - if ($newAllocationId) { - $allocationsToRevert = [$newAllocationId]; - if (!empty($newAdditionalAllocations)) { - $allocationsToRevert = array_merge($allocationsToRevert, $newAdditionalAllocations); - } - Allocation::unassignMultiple($allocationsToRevert); - } - - App::getInstance(true)->getLogger()->error('Failed to initiate server transfer: ' . $e->getMessage()); - - return ApiResponse::error('Failed to initiate server transfer: ' . $e->getMessage(), 'TRANSFER_INITIATION_FAILED', 500); } + + return ApiResponse::success([ + 'message' => 'Server transfer initiated successfully', + 'transfer_id' => $result['transfer_id'] ?? null, + 'new_allocation' => $result['new_allocation'] ?? null, + 'new_additional_allocations' => $result['new_additional_allocations'] ?? [], + ], 'Server transfer initiated', 200); } #[OA\Get( @@ -3044,6 +2992,34 @@ public function cancelTransfer(Request $request, int $id): Response } } + private function syncServerConfiguration(array $server): ?Response + { + $node = Node::getNodeById((int) $server['node_id']); + if (!$node) { + return ApiResponse::error('Node not found', 'NODE_NOT_FOUND', 404); + } + + try { + $wings = new Wings( + $node['fqdn'], + $node['daemonListen'], + $node['scheme'], + $node['daemon_token'], + 30 + ); + $response = $wings->getServer()->syncServer($server['uuid']); + if (!$response->isSuccessful()) { + return ApiResponse::error('Failed to sync server configuration: ' . $response->getError(), 'WINGS_ERROR', $response->getStatusCode()); + } + } catch (\Exception $e) { + App::getInstance(true)->getLogger()->error('Failed to sync server configuration: ' . $e->getMessage()); + + return ApiResponse::error('Failed to sync server configuration: ' . $e->getMessage(), 'SERVER_SYNC_FAILED', 500); + } + + return null; + } + /** * Clean up server databases when deleting a server. * This method handles database cleanup gracefully without breaking the deletion process. diff --git a/backend/app/Controllers/Admin/SettingsController.php b/backend/app/Controllers/Admin/SettingsController.php index df684aa05..61934b571 100755 --- a/backend/app/Controllers/Admin/SettingsController.php +++ b/backend/app/Controllers/Admin/SettingsController.php @@ -313,6 +313,9 @@ class SettingsController ConfigInterface::SERVER_ALLOW_USER_MADE_FASTDL, ConfigInterface::SERVER_ALLOW_USER_MADE_SUBDOMAINS, ConfigInterface::SERVER_HIDE_IPS, + ConfigInterface::FILE_TRASH_ENABLED, + ConfigInterface::FILE_TRASH_MAX_SIZE_MB, + ConfigInterface::FILE_TRASH_RETENTION_DAYS, ], ], 'chatbot' => [ @@ -1754,6 +1757,43 @@ public function __construct() 'options' => ['true', 'false'], 'category' => 'servers', ], + ConfigInterface::FILE_TRASH_ENABLED => [ + 'name' => ConfigInterface::FILE_TRASH_ENABLED, + 'value' => $this->app + ->getConfig() + ->getSetting(ConfigInterface::FILE_TRASH_ENABLED, 'false'), + 'description' => 'Enable a trash bin for server file deletions. Deleted files are moved to trash instead of being permanently removed, and users can restore or empty trash from the file manager.', + 'type' => 'select', + 'required' => true, + 'placeholder' => 'false', + 'validation' => 'required|string|max:255', + 'options' => ['true', 'false'], + 'category' => 'servers', + ], + ConfigInterface::FILE_TRASH_MAX_SIZE_MB => [ + 'name' => ConfigInterface::FILE_TRASH_MAX_SIZE_MB, + 'value' => $this->app + ->getConfig() + ->getSetting(ConfigInterface::FILE_TRASH_MAX_SIZE_MB, '512'), + 'description' => 'Maximum total size of trashed files per server, in megabytes. When exceeded, the oldest trashed items are permanently deleted. Use 0 for no size limit.', + 'type' => 'number', + 'required' => true, + 'placeholder' => '512', + 'validation' => 'required|integer|min:0', + 'category' => 'servers', + ], + ConfigInterface::FILE_TRASH_RETENTION_DAYS => [ + 'name' => ConfigInterface::FILE_TRASH_RETENTION_DAYS, + 'value' => $this->app + ->getConfig() + ->getSetting(ConfigInterface::FILE_TRASH_RETENTION_DAYS, '30'), + 'description' => 'Automatically purge trashed files older than this many days. Use 0 to keep items until manually deleted or the size limit is reached.', + 'type' => 'number', + 'required' => true, + 'placeholder' => '30', + 'validation' => 'required|integer|min:0', + 'category' => 'servers', + ], ConfigInterface::CHATBOT_ENABLED => [ 'name' => ConfigInterface::CHATBOT_ENABLED, 'value' => $this->app diff --git a/backend/app/Controllers/Admin/SpellsController.php b/backend/app/Controllers/Admin/SpellsController.php index e4170fdf7..57ca07811 100755 --- a/backend/app/Controllers/Admin/SpellsController.php +++ b/backend/app/Controllers/Admin/SpellsController.php @@ -830,6 +830,107 @@ public function getByRealm(Request $request, int $realmId): Response ], 'Spells for realm fetched successfully', 200); } + #[OA\Post( + path: '/api/admin/spells/reorder', + summary: 'Reorder spells within a realm', + description: 'Update the display sort order of spells in a realm (e.g. Node.js first, then Python, then Java).', + tags: ['Admin - Spells'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['realm_id', 'spells'], + properties: [ + new OA\Property(property: 'realm_id', type: 'integer', minimum: 1), + new OA\Property( + property: 'spells', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'sort_order', type: 'integer'), + ] + ) + ), + ] + ) + ), + responses: [ + new OA\Response(response: 200, description: 'Spells reordered successfully'), + new OA\Response(response: 400, description: 'Bad request'), + new OA\Response(response: 404, description: 'Realm not found'), + new OA\Response(response: 500, description: 'Internal server error'), + ] + )] + public function reorder(Request $request): Response + { + $data = json_decode($request->getContent(), true); + if (json_last_error() !== JSON_ERROR_NONE) { + return ApiResponse::error('Invalid JSON in request body', 'INVALID_JSON', 400); + } + + if (!isset($data['realm_id']) || !is_numeric($data['realm_id'])) { + return ApiResponse::error('realm_id is required', 'INVALID_REALM_ID', 400); + } + + $realmId = (int) $data['realm_id']; + if ($realmId <= 0) { + return ApiResponse::error('Invalid realm_id', 'INVALID_REALM_ID', 400); + } + + if (!Realm::getById($realmId)) { + return ApiResponse::error('Realm not found', 'REALM_NOT_FOUND', 404); + } + + if (!isset($data['spells']) || !is_array($data['spells']) || empty($data['spells'])) { + return ApiResponse::error('Spells array is required', 'INVALID_REQUEST', 400); + } + + $spells = []; + foreach ($data['spells'] as $spell) { + if (!isset($spell['id']) || !is_numeric($spell['id'])) { + continue; + } + if (!isset($spell['sort_order']) || !is_numeric($spell['sort_order'])) { + continue; + } + + $spells[] = [ + 'id' => (int) $spell['id'], + 'sort_order' => (int) $spell['sort_order'], + ]; + } + + if (empty($spells)) { + return ApiResponse::error('No valid spell order data provided', 'INVALID_SPELLS', 400); + } + + if (!Spell::updateSortOrders($spells, $realmId)) { + return ApiResponse::error('Failed to reorder spells', 'REORDER_FAILED', 500); + } + + $admin = $request->attributes->get('user'); + Activity::createActivity([ + 'user_uuid' => $admin['uuid'] ?? null, + 'name' => 'reorder_spells', + 'context' => 'Reordered ' . count($spells) . ' spells in realm ' . $realmId, + 'ip_address' => CloudFlareRealIP::getRealIP(), + ]); + + global $eventManager; + if (isset($eventManager) && $eventManager !== null) { + $eventManager->emit( + SpellsEvent::onSpellsReordered(), + [ + 'realm_id' => $realmId, + 'spells' => $spells, + 'reordered_by' => $admin, + ] + ); + } + + return ApiResponse::success([], 'Spells reordered successfully', 200); + } + #[OA\Post( path: '/api/admin/spells/import', summary: 'Import spell from file', diff --git a/backend/app/Controllers/Admin/UsersController.php b/backend/app/Controllers/Admin/UsersController.php index d73624490..878bfdea2 100755 --- a/backend/app/Controllers/Admin/UsersController.php +++ b/backend/app/Controllers/Admin/UsersController.php @@ -32,6 +32,7 @@ use App\Chat\Allocation; use App\Chat\VmInstance; use App\Helpers\UUIDUtils; +use App\Helpers\TimeHelper; use App\Helpers\ApiResponse; use OpenApi\Attributes as OA; use App\Config\ConfigInterface; @@ -40,6 +41,7 @@ use App\Helpers\EmailDomainValidator; use App\Mail\templates\AccountBanned; use App\Mail\templates\AccountDeleted; +use App\Helpers\ModerationReasonHelper; use App\Mail\templates\AccountUnBanned; use App\Plugins\Events\Events\UserEvent; use Symfony\Component\HttpFoundation\Request; @@ -55,6 +57,7 @@ new OA\Property(property: 'first_name', type: 'string', description: 'First name'), new OA\Property(property: 'last_name', type: 'string', description: 'Last name'), new OA\Property(property: 'email', type: 'string', format: 'email', description: 'Email address'), + new OA\Property(property: 'email_verified', type: 'boolean', description: 'Whether the user email is verified'), new OA\Property(property: 'avatar', type: 'string', format: 'uri', description: 'Avatar URL'), new OA\Property(property: 'last_seen', type: 'string', format: 'date-time', nullable: true, description: 'Last seen timestamp'), new OA\Property(property: 'banned', type: 'boolean', description: 'Banned status'), @@ -307,6 +310,7 @@ public function index(Request $request): Response 'avatar', 'last_seen', 'email', + 'mail_verify', 'oidc_provider', 'oidc_subject', 'ldap_provider_uuid', @@ -332,22 +336,26 @@ public function index(Request $request): Response } foreach ($users as &$user) { - $roleId = $user['role_id']; - if (isset($rolesMap[$roleId])) { - $user['role']['name'] = $rolesMap[$roleId]['name']; - $user['role']['display_name'] = $rolesMap[$roleId]['display_name']; - $user['role']['color'] = $rolesMap[$roleId]['color']; + $userRoleId = $user['role_id']; + if (isset($rolesMap[$userRoleId])) { + $user['role']['name'] = $rolesMap[$userRoleId]['name']; + $user['role']['display_name'] = $rolesMap[$userRoleId]['display_name']; + $user['role']['color'] = $rolesMap[$userRoleId]['color']; } else { - $user['role']['name'] = $roleId; + $user['role']['name'] = $userRoleId; $user['role']['display_name'] = 'User'; $user['role']['color'] = '#666666'; } + $user['email_verified'] = !isset($user['mail_verify']) || trim((string) $user['mail_verify']) === ''; if ($app->isDemoMode()) { $user['first_ip'] = $app->getIPIntoFBIFormat(); $user['last_ip'] = $app->getIPIntoFBIFormat(); } - unset($user['role_id']); + unset($user['role_id'], $user['mail_verify']); } + unset($user); + + $users = TimeHelper::normaliseRows($users, ['last_seen', 'first_seen']); $total = User::getCount( $search, @@ -435,7 +443,8 @@ public function show(Request $request, string $uuid): Response 'color' => $rolesMap[$roleId]['color'] ?? '#666666', ]; - unset($user['password']); + $user['email_verified'] = !isset($user['mail_verify']) || trim((string) $user['mail_verify']) === ''; + unset($user['password'], $user['mail_verify']); $user['activities'] = array_map(function ($activity) use ($app) { unset($activity['user_uuid'], $activity['id'], $activity['updated_at']); @@ -463,6 +472,9 @@ public function show(Request $request, string $uuid): Response $user['last_ip'] = $app->getIPIntoFBIFormat(); } + $user = ModerationReasonHelper::enrichUserBanMetadata($user); + $user = TimeHelper::normaliseRow($user, ['last_seen', 'first_seen', 'banned_at']); + return ApiResponse::success(['user' => $user, 'roles' => $rolesMap], 'User fetched successfully', 200); } @@ -525,7 +537,8 @@ public function showByExternalId(Request $request, string $externalId): Response 'color' => $rolesMap[$roleId]['color'] ?? '#666666', ]; - unset($user['password']); + $user['email_verified'] = !isset($user['mail_verify']) || trim((string) $user['mail_verify']) === ''; + unset($user['password'], $user['mail_verify']); $user['activities'] = array_map(function ($activity) use ($app) { unset($activity['user_uuid'], $activity['id'], $activity['updated_at']); @@ -554,6 +567,9 @@ public function showByExternalId(Request $request, string $externalId): Response $user['last_ip'] = $app->getIPIntoFBIFormat(); } + $user = ModerationReasonHelper::enrichUserBanMetadata($user); + $user = TimeHelper::normaliseRow($user, ['last_seen', 'first_seen', 'banned_at']); + return ApiResponse::success(['user' => $user, 'roles' => $rolesMap], 'User fetched successfully', 200); } @@ -822,6 +838,34 @@ public function update(Request $request, string $uuid): Response $data['password'] = password_hash($data['password'], PASSWORD_BCRYPT); $data['remember_token'] = User::generateAccountToken(); } + + $staffUser = $request->attributes->get('user'); + $wasBanned = $user['banned'] === 'true'; + $becameBanned = false; + $becameUnbanned = false; + $banReasonForEmail = ''; + + if (array_key_exists('banned', $data)) { + $wantsBanned = $data['banned'] === 'true' || $data['banned'] === true; + unset($data['reason_category'], $data['reason_details'], $data['reason']); + + if ($wantsBanned && !$wasBanned) { + $parsed = ModerationReasonHelper::parseRequestBody(json_decode($request->getContent(), true) ?: []); + $reasonError = ModerationReasonHelper::validateReason($parsed['reason']); + if ($reasonError !== null) { + return ApiResponse::error($reasonError, 'MODERATION_REASON_REQUIRED', 400); + } + $data = array_merge($data, ModerationReasonHelper::banAppliedFields($parsed['reason'], $staffUser)); + $banReasonForEmail = $parsed['reason']; + $becameBanned = true; + } elseif (!$wantsBanned && $wasBanned) { + $data = array_merge($data, ModerationReasonHelper::banClearedFields()); + $becameUnbanned = true; + } elseif ($wantsBanned && $wasBanned) { + unset($data['banned']); + } + } + $updated = User::updateUser($user['uuid'], $data); if (!$updated) { return ApiResponse::error('Failed to update user', 'FAILED_TO_UPDATE_USER', 500, [ @@ -843,36 +887,35 @@ public function update(Request $request, string $uuid): Response ); } - if (isset($data['banned'])) { - if ($data['banned'] == 'true') { - AccountBanned::send([ - 'email' => $user['email'], - 'subject' => 'Your account has been suspended on ' . $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'), - 'app_name' => $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'), - 'app_url' => $config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems'), - 'first_name' => $user['first_name'], - 'last_name' => $user['last_name'], - 'username' => $user['username'], - 'app_support_url' => $config->getSetting(ConfigInterface::APP_SUPPORT_URL, 'https://discord.mythical.systems'), - 'uuid' => $user['uuid'], - 'enabled' => $config->getSetting(ConfigInterface::SMTP_ENABLED, 'false'), - 'suspension_time' => date('Y-m-d H:i:s'), - ]); - } else { - AccountUnBanned::send([ - 'email' => $user['email'], - 'subject' => 'Your account has been unsuspended on ' . $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'), - 'app_name' => $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'), - 'app_url' => $config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems'), - 'first_name' => $user['first_name'], - 'last_name' => $user['last_name'], - 'username' => $user['username'], - 'app_support_url' => $config->getSetting(ConfigInterface::APP_SUPPORT_URL, 'https://discord.mythical.systems'), - 'uuid' => $user['uuid'], - 'enabled' => $config->getSetting(ConfigInterface::SMTP_ENABLED, 'false'), - 'unsuspension_time' => date('Y-m-d H:i:s'), - ]); - } + if ($becameBanned) { + AccountBanned::send([ + 'email' => $user['email'], + 'subject' => 'Your account has been suspended on ' . $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'), + 'app_name' => $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'), + 'app_url' => $config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems'), + 'first_name' => $user['first_name'], + 'last_name' => $user['last_name'], + 'username' => $user['username'], + 'app_support_url' => $config->getSetting(ConfigInterface::APP_SUPPORT_URL, 'https://discord.mythical.systems'), + 'uuid' => $user['uuid'], + 'enabled' => $config->getSetting(ConfigInterface::SMTP_ENABLED, 'false'), + 'suspension_time' => date('Y-m-d H:i:s'), + 'suspension_reason' => $banReasonForEmail, + ]); + } elseif ($becameUnbanned) { + AccountUnBanned::send([ + 'email' => $user['email'], + 'subject' => 'Your account has been unsuspended on ' . $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'), + 'app_name' => $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'), + 'app_url' => $config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems'), + 'first_name' => $user['first_name'], + 'last_name' => $user['last_name'], + 'username' => $user['username'], + 'app_support_url' => $config->getSetting(ConfigInterface::APP_SUPPORT_URL, 'https://discord.mythical.systems'), + 'uuid' => $user['uuid'], + 'enabled' => $config->getSetting(ConfigInterface::SMTP_ENABLED, 'false'), + 'unsuspension_time' => date('Y-m-d H:i:s'), + ]); } return ApiResponse::success([], 'User updated successfully', 200); @@ -1391,6 +1434,74 @@ public function sendEmail(Request $request, string $uuid): Response ], 'Email queued successfully', 200); } + #[OA\Post( + path: '/api/admin/users/{uuid}/verify-email', + summary: 'Force verify user email', + description: 'Mark a user email address as verified by clearing their pending email verification token.', + tags: ['Admin - Users'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'User UUID', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + ], + responses: [ + new OA\Response(response: 200, description: 'User email verified successfully'), + new OA\Response(response: 400, description: 'Bad request - User is already verified or demo mode restriction'), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden - Insufficient permissions'), + new OA\Response(response: 404, description: 'User not found'), + new OA\Response(response: 500, description: 'Internal server error - Failed to verify email'), + ] + )] + public function forceVerifyEmail(Request $request, string $uuid): Response + { + $user = User::getUserByUuid($uuid); + if (!$user) { + return ApiResponse::error('User not found', 'USER_NOT_FOUND', 404); + } + + $app = App::getInstance(true); + if ($app->isDemoMode() && in_array((int) $user['id'], [1, 2], true)) { + return ApiResponse::error('Unmanaged actions are not permitted in demo mode', 'UNMANAGED_ACTIONS_NOT_PERMITTED', 400); + } + + if (!isset($user['mail_verify']) || trim((string) $user['mail_verify']) === '') { + return ApiResponse::error('User email is already verified', 'EMAIL_ALREADY_VERIFIED', 400); + } + + $updated = User::updateUser($user['uuid'], ['mail_verify' => null]); + if (!$updated) { + return ApiResponse::error('Failed to verify user email', 'FAILED_TO_VERIFY_EMAIL', 500); + } + + Activity::createActivity([ + 'user_uuid' => $request->attributes->get('user')['uuid'] ?? null, + 'name' => 'force_verify_user_email', + 'context' => 'Force verified email for user ' . ($user['username'] ?? $user['uuid']), + 'ip_address' => CloudFlareRealIP::getRealIP(), + ]); + + global $eventManager; + if (isset($eventManager) && $eventManager !== null) { + $eventManager->emit( + UserEvent::onUserUpdated(), + [ + 'user' => $user, + 'updated_data' => ['mail_verify' => null], + 'updated_by' => $request->attributes->get('user'), + ] + ); + } + + $app->getLogger()->info('User ' . $user['uuid'] . ' email force verified by ' . ($request->attributes->get('user')['uuid'] ?? 'unknown')); + + return ApiResponse::success([], 'User email verified successfully', 200); + } + #[OA\Post( path: '/api/admin/users/{uuid}/ban', summary: 'Ban a user', @@ -1432,7 +1543,18 @@ public function ban(Request $request, string $uuid): Response } } - $updated = User::updateUser($user['uuid'], ['banned' => 'true']); + $body = json_decode($request->getContent(), true); + if (!is_array($body)) { + $body = []; + } + $parsed = ModerationReasonHelper::parseRequestBody($body); + $reasonError = ModerationReasonHelper::validateReason($parsed['reason']); + if ($reasonError !== null) { + return ApiResponse::error($reasonError, 'MODERATION_REASON_REQUIRED', 400); + } + + $staffUser = $request->attributes->get('user'); + $updated = User::updateUser($user['uuid'], ModerationReasonHelper::banAppliedFields($parsed['reason'], $staffUser)); if (!$updated) { return ApiResponse::error('Failed to ban user', 'FAILED_TO_BAN_USER', 500); } @@ -1443,8 +1565,8 @@ public function ban(Request $request, string $uuid): Response UserEvent::onUserUpdated(), [ 'user' => $user, - 'updated_data' => ['banned' => 'true'], - 'updated_by' => $request->attributes->get('user'), + 'updated_data' => array_merge(ModerationReasonHelper::banAppliedFields($parsed['reason'], $staffUser), ['ban_reason' => $parsed['reason']]), + 'updated_by' => $staffUser, ] ); } @@ -1462,6 +1584,7 @@ public function ban(Request $request, string $uuid): Response 'uuid' => $user['uuid'], 'enabled' => $config->getSetting(ConfigInterface::SMTP_ENABLED, 'false'), 'suspension_time' => date('Y-m-d H:i:s'), + 'suspension_reason' => $parsed['reason'], ]); $app->getLogger()->info('User ' . $user['uuid'] . ' banned by ' . ($request->attributes->get('user')['uuid'] ?? 'unknown')); @@ -1510,7 +1633,7 @@ public function unban(Request $request, string $uuid): Response } } - $updated = User::updateUser($user['uuid'], ['banned' => 'false']); + $updated = User::updateUser($user['uuid'], ModerationReasonHelper::banClearedFields()); if (!$updated) { return ApiResponse::error('Failed to unban user', 'FAILED_TO_UNBAN_USER', 500); } @@ -1521,7 +1644,7 @@ public function unban(Request $request, string $uuid): Response UserEvent::onUserUpdated(), [ 'user' => $user, - 'updated_data' => ['banned' => 'false'], + 'updated_data' => ModerationReasonHelper::banClearedFields(), 'updated_by' => $request->attributes->get('user'), ] ); diff --git a/backend/app/Controllers/Admin/VmInstancesController.php b/backend/app/Controllers/Admin/VmInstancesController.php index e5729625e..47c05c15d 100644 --- a/backend/app/Controllers/Admin/VmInstancesController.php +++ b/backend/app/Controllers/Admin/VmInstancesController.php @@ -27,6 +27,7 @@ use App\Chat\VmInstance; use App\Chat\VmTemplate; use App\Chat\VmInstanceIp; +use App\Helpers\TimeHelper; use App\Helpers\ApiResponse; use OpenApi\Attributes as OA; use App\Chat\VmInstanceBackup; @@ -36,6 +37,7 @@ use App\Services\Vm\VmInstanceUtil; use App\CloudFlare\CloudFlareRealIP; use App\Mail\templates\VmUnsuspended; +use App\Helpers\ModerationReasonHelper; use App\Plugins\Events\Events\VdsEvent; use App\Services\Backup\BackupFifoEviction; use Symfony\Component\HttpFoundation\Request; @@ -615,6 +617,9 @@ public function show(Request $request, int $id): Response $assignedIps = VmInstanceIp::getByInstanceId($id); + $instance = ModerationReasonHelper::enrichServerSuspensionMetadata($instance); + $instance = TimeHelper::normaliseRow($instance, ['suspended_at', 'created_at', 'updated_at', 'expires_at']); + return ApiResponse::success( ['instance' => array_merge($instance, $extra, ['assigned_ips' => $assignedIps])], 'VM instance fetched successfully', @@ -2550,7 +2555,18 @@ public function suspend(Request $request, int $id): Response return ApiResponse::error('VM instance not found', 'VM_INSTANCE_NOT_FOUND', 404); } - $ok = VmInstance::update($id, ['suspended' => 1]); + $body = json_decode($request->getContent(), true); + if (!is_array($body)) { + $body = []; + } + $parsed = ModerationReasonHelper::parseRequestBody($body); + $reasonError = ModerationReasonHelper::validateReason($parsed['reason']); + if ($reasonError !== null) { + return ApiResponse::error($reasonError, 'MODERATION_REASON_REQUIRED', 400); + } + + $admin = $request->attributes->get('user'); + $ok = VmInstance::update($id, ModerationReasonHelper::suspensionAppliedFields($parsed['reason'], $admin)); if (!$ok) { return ApiResponse::error('Failed to suspend VM instance', 'FAILED_TO_SUSPEND', 500); } @@ -2561,11 +2577,10 @@ public function suspend(Request $request, int $id): Response $user = User::getUserByUuid($instance['user_uuid']); } - $admin = $request->attributes->get('user'); Activity::createActivity([ 'user_uuid' => $admin['uuid'] ?? null, 'name' => 'vm_instance_suspend', - 'context' => 'Suspended VM instance ' . ($instance['hostname'] ?? $id), + 'context' => 'Suspended VM instance ' . ($instance['hostname'] ?? $id) . ' — ' . $parsed['reason'], 'ip_address' => CloudFlareRealIP::getRealIP(), ]); @@ -2617,6 +2632,7 @@ public function suspend(Request $request, int $id): Response 'uuid' => $user['uuid'], 'enabled' => $config->getSetting(ConfigInterface::SMTP_ENABLED, 'false'), 'vm_hostname' => $instance['hostname'] ?? 'VM-' . $id, + 'suspension_reason' => $parsed['reason'], ]); } catch (\Exception $e) { App::getInstance(true)->getLogger()->error('Failed to send VM suspended email: ' . $e->getMessage()); @@ -2663,7 +2679,7 @@ public function unsuspend(Request $request, int $id): Response return ApiResponse::error('VM instance not found', 'VM_INSTANCE_NOT_FOUND', 404); } - $ok = VmInstance::update($id, ['suspended' => 0]); + $ok = VmInstance::update($id, ModerationReasonHelper::suspensionClearedFields()); if (!$ok) { return ApiResponse::error('Failed to unsuspend VM instance', 'FAILED_TO_UNSUSPEND', 500); } diff --git a/backend/app/Controllers/System/PluginSidebarController.php b/backend/app/Controllers/System/PluginSidebarController.php index 877f86ba1..99ad7947d 100755 --- a/backend/app/Controllers/System/PluginSidebarController.php +++ b/backend/app/Controllers/System/PluginSidebarController.php @@ -51,6 +51,7 @@ new OA\Property(property: 'lucideIcon', type: 'string', nullable: true, description: 'Lucide icon name (e.g., "camera", "search"). If provided, this will be used instead of the icon field. See https://lucide.dev/icons/ for available icons.'), new OA\Property(property: 'permission', type: 'string', nullable: true, description: 'Required permission for this item'), new OA\Property(property: 'group', type: 'string', nullable: true, description: 'Group name for organizing items (e.g., "Minecraft Java Edition"). Items with the same group name will be grouped together.'), + new OA\Property(property: 'priority', type: 'integer', nullable: true, description: 'Sort order within the sidebar group. Lower numbers render first.'), ] )] class PluginSidebarController diff --git a/backend/app/Controllers/User/Auth/LdapController.php b/backend/app/Controllers/User/Auth/LdapController.php index acd774f3b..76903f539 100644 --- a/backend/app/Controllers/User/Auth/LdapController.php +++ b/backend/app/Controllers/User/Auth/LdapController.php @@ -191,6 +191,94 @@ public function login(Request $request): Response return $loginController->completeLogin($userInfo); } + /** + * Link an LDAP directory identity to the authenticated account. + */ + public function link(Request $request): Response + { + $currentUser = $request->attributes->get('user'); + if (!$currentUser || empty($currentUser['uuid'])) { + return ApiResponse::error('Unauthorized', 'UNAUTHORIZED', 401); + } + + $data = json_decode($request->getContent(), true); + if (!is_array($data)) { + return ApiResponse::error('Invalid request data', 'INVALID_REQUEST_DATA', 400); + } + + $providerUuid = isset($data['provider_uuid']) ? trim((string) $data['provider_uuid']) : ''; + $username = isset($data['username']) ? trim((string) $data['username']) : ''; + $password = isset($data['password']) ? (string) $data['password'] : ''; + + if ($providerUuid === '' || $username === '' || $password === '') { + return ApiResponse::error( + 'Provider UUID, username, and password are required', + 'MISSING_REQUIRED_FIELDS', + 400 + ); + } + + $provider = LdapProvider::getProviderByUuid($providerUuid); + if (!$provider) { + return ApiResponse::error('LDAP provider not found', 'PROVIDER_NOT_FOUND', 404); + } + + if (($provider['enabled'] ?? 'false') !== 'true') { + return ApiResponse::error('LDAP provider is disabled', 'PROVIDER_DISABLED', 403); + } + + $ldap = new LdapAuthenticator($provider); + $ldapUser = $ldap->authenticate($username, $password); + if (!$ldapUser) { + return ApiResponse::error( + 'LDAP authentication failed: ' . ($ldap->getLastError() ?? 'Unknown error'), + 'LDAP_AUTH_FAILED', + 401 + ); + } + + $linkedUser = User::getUserByLdapProviderAndDn($providerUuid, $ldapUser['dn']); + if ($linkedUser && $linkedUser['uuid'] !== $currentUser['uuid']) { + return ApiResponse::error('LDAP account is already linked to another user', 'LDAP_ALREADY_LINKED', 409); + } + + $updated = User::updateUser($currentUser['uuid'], [ + 'ldap_provider_uuid' => $providerUuid, + 'ldap_dn' => $ldapUser['dn'], + ]); + + if (!$updated) { + return ApiResponse::error('Failed to link LDAP account', 'LDAP_LINK_FAILED', 500); + } + + return ApiResponse::success([ + 'provider_uuid' => $providerUuid, + 'dn' => $ldapUser['dn'], + ], 'LDAP account linked successfully', 200); + } + + /** + * Unlink LDAP from the authenticated account. + */ + public function unlink(Request $request): Response + { + $currentUser = $request->attributes->get('user'); + if (!$currentUser || empty($currentUser['uuid'])) { + return ApiResponse::error('Unauthorized', 'UNAUTHORIZED', 401); + } + + $updated = User::updateUser($currentUser['uuid'], [ + 'ldap_provider_uuid' => null, + 'ldap_dn' => null, + ]); + + if (!$updated) { + return ApiResponse::error('Failed to unlink LDAP account', 'LDAP_UNLINK_FAILED', 500); + } + + return ApiResponse::success([], 'LDAP account unlinked successfully', 200); + } + /** * Provision a new user from LDAP data. */ diff --git a/backend/app/Controllers/User/Auth/OidcController.php b/backend/app/Controllers/User/Auth/OidcController.php index 0420fb565..17dd4026e 100644 --- a/backend/app/Controllers/User/Auth/OidcController.php +++ b/backend/app/Controllers/User/Auth/OidcController.php @@ -20,6 +20,7 @@ use App\App; use App\Chat\User; use App\Cache\Cache; +use App\Helpers\ApiResponse; use OpenApi\Attributes as OA; use App\Config\ConfigInterface; use App\Helpers\EmailDomainValidator; @@ -50,53 +51,22 @@ class OidcController )] public function login(Request $request): RedirectResponse { - $app = App::getInstance(true); - $providerUuid = $request->query->get('provider'); - - if (!is_string($providerUuid) || $providerUuid === '') { - return new RedirectResponse('/auth/login?error=oidc_provider_missing'); - } - - $provider = \App\Chat\OidcProvider::getProviderByUuid($providerUuid); - if (!$provider || ($provider['enabled'] ?? 'true') !== 'true') { - return new RedirectResponse('/auth/login?error=oidc_provider_not_found'); - } - - $issuerUrl = rtrim((string) $provider['issuer_url'], '/'); - $clientId = (string) $provider['client_id']; - $scopes = (string) ($provider['scopes'] ?? 'openid email profile'); - - if ($issuerUrl === '' || $clientId === '') { - return new RedirectResponse('/auth/login?error=oidc_not_configured'); - } + return $this->startOidcFlow($request); + } - $discoveryUrl = $issuerUrl . '/.well-known/openid-configuration'; - $discovery = $this->fetchJson($discoveryUrl); - if (!is_array($discovery) || !isset($discovery['authorization_endpoint'])) { - return new RedirectResponse('/auth/login?error=oidc_discovery_failed'); + /** + * Initiate OIDC account linking for an authenticated user. + * + * GET /api/user/auth/oidc/link. + */ + public function link(Request $request): RedirectResponse + { + $user = $request->attributes->get('user'); + if (!$user || empty($user['uuid'])) { + return new RedirectResponse('/auth/login?redirect=' . urlencode('/dashboard/account?tab=settings')); } - $authEndpoint = $discovery['authorization_endpoint']; - - $state = bin2hex(random_bytes(16)); - $nonce = bin2hex(random_bytes(16)); - - Cache::put('oidc_state_' . $state, [ - 'nonce' => $nonce, - 'provider_uuid' => $providerUuid, - ], 10); - - $redirectUri = $app->getConfig()->getSetting(ConfigInterface::APP_URL, '') . '/api/user/auth/oidc/callback'; - $query = http_build_query([ - 'client_id' => $clientId, - 'redirect_uri' => $redirectUri, - 'response_type' => 'code', - 'scope' => $scopes, - 'state' => $state, - 'nonce' => $nonce, - ]); - - return new RedirectResponse($authEndpoint . '?' . $query); + return $this->startOidcFlow($request, (string) $user['uuid']); } /** @@ -134,19 +104,21 @@ public function callback(Request $request): RedirectResponse | Response return new RedirectResponse('/auth/login?error=oidc_missing_code'); } + $isLinkFlow = isset($cached['link_user_uuid']) && is_string($cached['link_user_uuid']) && $cached['link_user_uuid'] !== ''; + Cache::forget('oidc_state_' . $state); $providerUuid = (string) $cached['provider_uuid']; $provider = \App\Chat\OidcProvider::getProviderByUuid($providerUuid); if (!$provider || ($provider['enabled'] ?? 'true') !== 'true') { - return new RedirectResponse('/auth/login?error=oidc_provider_not_found'); + return $this->oidcErrorRedirect('oidc_provider_not_found', $isLinkFlow); } $issuerUrl = rtrim((string) $provider['issuer_url'], '/'); $clientId = (string) $provider['client_id']; $clientSecretRaw = $provider['client_secret'] ?? ''; if ($clientSecretRaw === '') { - return new RedirectResponse('/auth/login?error=oidc_not_configured'); + return $this->oidcErrorRedirect('oidc_not_configured', $isLinkFlow); } try { $clientSecret = $app->decryptValue((string) $clientSecretRaw); @@ -156,73 +128,120 @@ public function callback(Request $request): RedirectResponse | Response } if ($issuerUrl === '' || $clientId === '' || $clientSecret === '') { - return new RedirectResponse('/auth/login?error=oidc_not_configured'); + return $this->oidcErrorRedirect('oidc_not_configured', $isLinkFlow); } $discoveryUrl = $issuerUrl . '/.well-known/openid-configuration'; $discovery = $this->fetchJson($discoveryUrl); if (!is_array($discovery) || !isset($discovery['token_endpoint'])) { - return new RedirectResponse('/auth/login?error=oidc_discovery_failed'); + return $this->oidcErrorRedirect('oidc_discovery_failed', $isLinkFlow); } $tokenEndpoint = $discovery['token_endpoint']; $redirectUri = $app->getConfig()->getSetting(ConfigInterface::APP_URL, '') . '/api/user/auth/oidc/callback'; - $postData = http_build_query([ + $basePostFields = [ 'grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => $redirectUri, - 'client_id' => $clientId, - 'client_secret' => $clientSecret, - ]); + ]; + + $supportedTokenAuthMethods = $discovery['token_endpoint_auth_methods_supported'] ?? null; + $tokenAuthMethods = ['client_secret_basic']; + if (is_array($supportedTokenAuthMethods)) { + $tokenAuthMethods = []; + foreach (['client_secret_basic', 'client_secret_post'] as $method) { + if (in_array($method, $supportedTokenAuthMethods, true)) { + $tokenAuthMethods[] = $method; + } + } + if ($tokenAuthMethods === []) { + $tokenAuthMethods = ['client_secret_basic', 'client_secret_post']; + } + } else { + $tokenAuthMethods[] = 'client_secret_post'; + } - $ch = curl_init($tokenEndpoint); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, $postData); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_FAILONERROR, true); - $tokenResponse = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $curlErr = curl_errno($ch); - $curlErrMsg = curl_error($ch); - curl_close($ch); + $tokenResponse = false; + $httpCode = 0; + $curlErr = 0; + $curlErrMsg = ''; + $tokenAuthMethod = $tokenAuthMethods[0]; + + foreach ($tokenAuthMethods as $method) { + $postFields = $basePostFields; + $headers = ['Content-Type: application/x-www-form-urlencoded']; + + if ($method === 'client_secret_post') { + $postFields['client_id'] = $clientId; + $postFields['client_secret'] = $clientSecret; + } else { + $basicCredentials = base64_encode(rawurlencode($clientId) . ':' . rawurlencode($clientSecret)); + $headers[] = 'Authorization: Basic ' . $basicCredentials; + } + + $ch = curl_init($tokenEndpoint); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postFields)); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + $tokenResponse = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErr = curl_errno($ch); + $curlErrMsg = curl_error($ch); + $ch = null; + $tokenAuthMethod = $method; + + if ($curlErr === 0 && $tokenResponse !== false && $httpCode >= 200 && $httpCode < 300) { + break; + } + + $responseSnippet = substr((string) $tokenResponse, 0, 500); + $app->getLogger()->warning( + 'OIDC token request using ' . $method . ' failed with HTTP ' . $httpCode . '. Response: ' . $responseSnippet + ); + } if ($curlErr !== 0 || $tokenResponse === false) { - $app->getLogger()->warning('OIDC token request failed: ' . ($curlErrMsg ?: 'Unknown cURL error')); + $app->getLogger()->warning( + 'OIDC token request using ' . $tokenAuthMethod . ' failed: ' . ($curlErrMsg ?: 'Unknown cURL error') + ); - return new RedirectResponse('/auth/login?error=oidc_token_failed'); + return $this->oidcErrorRedirect('oidc_token_failed', $isLinkFlow); } if ($httpCode < 200 || $httpCode >= 300) { - $app->getLogger()->warning('OIDC token endpoint returned HTTP ' . $httpCode); + $responseSnippet = substr((string) $tokenResponse, 0, 500); + $app->getLogger()->warning( + 'OIDC token endpoint returned HTTP ' . $httpCode . ' using ' . $tokenAuthMethod . '. Response: ' . $responseSnippet + ); - return new RedirectResponse('/auth/login?error=oidc_token_failed'); + return $this->oidcErrorRedirect('oidc_token_failed', $isLinkFlow); } $tokenData = json_decode($tokenResponse ?: '', true); if (!is_array($tokenData) || !isset($tokenData['id_token'])) { - return new RedirectResponse('/auth/login?error=oidc_token_failed'); + return $this->oidcErrorRedirect('oidc_token_failed', $isLinkFlow); } $idToken = $tokenData['id_token']; $claims = $this->decodeJwtWithoutVerification($idToken); if (!is_array($claims)) { - return new RedirectResponse('/auth/login?error=oidc_invalid_id_token'); + return $this->oidcErrorRedirect('oidc_invalid_id_token', $isLinkFlow); } if (!isset($claims['iss']) || !is_string($claims['iss']) || rtrim($claims['iss'], '/') !== $issuerUrl) { - return new RedirectResponse('/auth/login?error=oidc_invalid_issuer'); + return $this->oidcErrorRedirect('oidc_invalid_issuer', $isLinkFlow); } if (!isset($claims['aud'])) { - return new RedirectResponse('/auth/login?error=oidc_invalid_audience'); + return $this->oidcErrorRedirect('oidc_invalid_audience', $isLinkFlow); } $aud = $claims['aud']; if (is_string($aud) && $aud !== $clientId) { - return new RedirectResponse('/auth/login?error=oidc_invalid_audience'); + return $this->oidcErrorRedirect('oidc_invalid_audience', $isLinkFlow); } if (is_array($aud) && !in_array($clientId, $aud, true)) { - return new RedirectResponse('/auth/login?error=oidc_invalid_audience'); + return $this->oidcErrorRedirect('oidc_invalid_audience', $isLinkFlow); } $emailClaimKey = (string) ($provider['email_claim'] ?? 'email'); @@ -233,7 +252,7 @@ public function callback(Request $request): RedirectResponse | Response $subject = $claims[$subjectClaimKey] ?? null; if (!is_string($subject) || $subject === '') { - return new RedirectResponse('/auth/login?error=oidc_missing_subject'); + return $this->oidcErrorRedirect('oidc_missing_subject', $isLinkFlow); } $email = $claims[$emailClaimKey] ?? null; @@ -244,7 +263,7 @@ public function callback(Request $request): RedirectResponse | Response if ($requireEmailVerified) { $emailVerified = $claims['email_verified'] ?? null; if ($emailVerified !== true && $emailVerified !== 'true') { - return new RedirectResponse('/auth/login?error=oidc_email_not_verified'); + return $this->oidcErrorRedirect('oidc_email_not_verified', $isLinkFlow); } } @@ -257,7 +276,7 @@ public function callback(Request $request): RedirectResponse | Response $allowed = in_array($requiredGroupValue, $groupClaim, true); } if (!$allowed) { - return new RedirectResponse('/auth/login?error=oidc_access_denied'); + return $this->oidcErrorRedirect('oidc_access_denied', $isLinkFlow); } } @@ -266,6 +285,10 @@ public function callback(Request $request): RedirectResponse | Response $providerId = $providerUuid; + if ($isLinkFlow) { + return $this->completeAccountLink($cached['link_user_uuid'], $providerId, $subject, $email); + } + $user = $this->findUserByOidcSubject($providerId, $subject); if (!$user && $email && $emailVerifiedStrict) { $user = User::getUserByEmail($email); @@ -298,6 +321,116 @@ public function callback(Request $request): RedirectResponse | Response return $loginController->completeLogin($user, '/dashboard'); } + /** + * Unlink OIDC from the authenticated account. + * + * DELETE /api/user/auth/oidc/unlink. + */ + public function unlink(Request $request): Response + { + $user = $request->attributes->get('user'); + if (!$user || empty($user['uuid'])) { + return ApiResponse::error('Unauthorized', 'UNAUTHORIZED', 401); + } + + $updated = User::updateUser((string) $user['uuid'], [ + 'oidc_provider' => null, + 'oidc_subject' => null, + 'oidc_email' => null, + ]); + + if (!$updated) { + return ApiResponse::error('Failed to unlink OIDC account', 'OIDC_UNLINK_FAILED', 500); + } + + return ApiResponse::success([], 'OIDC account unlinked successfully', 200); + } + + private function startOidcFlow(Request $request, ?string $linkUserUuid = null): RedirectResponse + { + $app = App::getInstance(true); + $providerUuid = $request->query->get('provider'); + + if (!is_string($providerUuid) || $providerUuid === '') { + return $this->oidcErrorRedirect('oidc_provider_missing', $linkUserUuid !== null); + } + + $provider = \App\Chat\OidcProvider::getProviderByUuid($providerUuid); + if (!$provider || ($provider['enabled'] ?? 'true') !== 'true') { + return $this->oidcErrorRedirect('oidc_provider_not_found', $linkUserUuid !== null); + } + + $issuerUrl = rtrim((string) $provider['issuer_url'], '/'); + $clientId = (string) $provider['client_id']; + $scopes = (string) ($provider['scopes'] ?? 'openid email profile'); + + if ($issuerUrl === '' || $clientId === '') { + return $this->oidcErrorRedirect('oidc_not_configured', $linkUserUuid !== null); + } + + $discoveryUrl = $issuerUrl . '/.well-known/openid-configuration'; + $discovery = $this->fetchJson($discoveryUrl); + if (!is_array($discovery) || !isset($discovery['authorization_endpoint'])) { + return $this->oidcErrorRedirect('oidc_discovery_failed', $linkUserUuid !== null); + } + + $state = bin2hex(random_bytes(16)); + $nonce = bin2hex(random_bytes(16)); + $stateData = [ + 'nonce' => $nonce, + 'provider_uuid' => $providerUuid, + ]; + if ($linkUserUuid !== null) { + $stateData['link_user_uuid'] = $linkUserUuid; + } + + Cache::put('oidc_state_' . $state, $stateData, 10); + + $redirectUri = $app->getConfig()->getSetting(ConfigInterface::APP_URL, '') . '/api/user/auth/oidc/callback'; + $query = http_build_query([ + 'client_id' => $clientId, + 'redirect_uri' => $redirectUri, + 'response_type' => 'code', + 'scope' => $scopes, + 'state' => $state, + 'nonce' => $nonce, + ]); + + return new RedirectResponse($discovery['authorization_endpoint'] . '?' . $query); + } + + private function oidcErrorRedirect(string $errorCode, bool $accountSettings): RedirectResponse + { + $target = $accountSettings ? '/dashboard/account?tab=settings&error=' : '/auth/login?error='; + + return new RedirectResponse($target . urlencode($errorCode)); + } + + private function completeAccountLink(string $userUuid, string $providerId, string $subject, ?string $email): RedirectResponse + { + $user = User::getUserByUuid($userUuid); + if (!$user) { + return new RedirectResponse('/dashboard/account?tab=settings&error=oidc_user_not_found'); + } + + $existingLinkedUser = $this->findUserByOidcSubject($providerId, $subject); + if ($existingLinkedUser && $existingLinkedUser['uuid'] !== $userUuid) { + return new RedirectResponse('/dashboard/account?tab=settings&error=oidc_already_linked'); + } + + $updated = User::updateUser($userUuid, [ + 'oidc_provider' => $providerId, + 'oidc_subject' => $subject, + 'oidc_email' => $email, + ]); + + if (!$updated) { + return new RedirectResponse('/dashboard/account?tab=settings&error=oidc_link_failed'); + } + + return new RedirectResponse('/dashboard/account?tab=settings&linked=oidc'); + } + /** * Find a user by OIDC provider and subject. */ @@ -426,7 +559,7 @@ private function fetchJson(string $url): ?array $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_errno($ch); - curl_close($ch); + $ch = null; if ($curlErr !== 0 || $response === false || $httpCode < 200 || $httpCode >= 300) { return null; } diff --git a/backend/app/Controllers/User/Auth/PasskeyController.php b/backend/app/Controllers/User/Auth/PasskeyController.php index 02ae80397..c0a2007d3 100644 --- a/backend/app/Controllers/User/Auth/PasskeyController.php +++ b/backend/app/Controllers/User/Auth/PasskeyController.php @@ -23,6 +23,7 @@ use App\Permissions; use Cose\Algorithms; use App\Chat\UserPasskey; +use App\Helpers\TimeHelper; use App\Helpers\ApiResponse; use App\Config\ConfigInterface; use App\Helpers\WebAuthnHelper; @@ -260,7 +261,7 @@ public function getList(Request $request): Response $out[] = [ 'id' => (int) $row['id'], 'label' => $row['label'], - 'created_at' => $row['created_at'], + 'created_at' => TimeHelper::toIso8601($row['created_at'] ?? null), 'aaguid' => $row['aaguid'], ]; } diff --git a/backend/app/Controllers/User/Auth/VerifyEmailController.php b/backend/app/Controllers/User/Auth/VerifyEmailController.php index c035fc894..e362e386b 100644 --- a/backend/app/Controllers/User/Auth/VerifyEmailController.php +++ b/backend/app/Controllers/User/Auth/VerifyEmailController.php @@ -17,13 +17,19 @@ namespace App\Controllers\User\Auth; +use App\App; use App\Chat\User; +use App\Cache\Cache; use App\Helpers\ApiResponse; +use App\Config\ConfigInterface; +use App\Mail\templates\VerifyEmail; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class VerifyEmailController { + private const RESEND_COOLDOWN_MINUTES = 5; + public function get(Request $request): Response { $token = trim((string) $request->query->get('token', '')); @@ -42,4 +48,59 @@ public function get(Request $request): Response return ApiResponse::success([], 'Email verified successfully. You can now log in.', 200); } + + public function resend(Request $request): Response + { + $app = App::getInstance(true); + $config = $app->getConfig(); + + if ($config->getSetting(ConfigInterface::REGISTRATION_REQUIRE_EMAIL_VERIFICATION, 'false') !== 'true') { + return ApiResponse::success([], 'If email verification is needed, a new verification email will be sent.', 200); + } + + if ($config->getSetting(ConfigInterface::SMTP_ENABLED, 'false') !== 'true') { + return ApiResponse::error('Email verification is enabled, but SMTP is not configured.', 'EMAIL_VERIFICATION_SMTP_REQUIRED', 400); + } + + $body = json_decode($request->getContent(), true); + if (!is_array($body)) { + $body = []; + } + $identifier = trim((string) ($body['email'] ?? $body['username_or_email'] ?? '')); + if ($identifier === '') { + return ApiResponse::error('Email or username is required', 'MISSING_IDENTIFIER', 400); + } + + $user = filter_var($identifier, FILTER_VALIDATE_EMAIL) + ? User::getUserByEmail($identifier) + : User::getUserByUsername($identifier); + + if ($user === null || !isset($user['mail_verify']) || trim((string) $user['mail_verify']) === '') { + return ApiResponse::success([], 'If email verification is needed, a new verification email will be sent.', 200); + } + + $cacheKey = 'auth:email-verification-resend:' . hash('sha256', (string) $user['uuid']); + if (Cache::exists($cacheKey)) { + return ApiResponse::success([], 'If email verification is needed, a new verification email will be sent.', 200); + } + + $verifyUrl = rtrim($config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems'), '/') . '/auth/verify-email?token=' . urlencode((string) $user['mail_verify']); + VerifyEmail::send([ + 'subject' => 'Verify your email for ' . $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'), + 'app_name' => $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'), + 'app_url' => $config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems'), + 'first_name' => (string) ($user['first_name'] ?? ''), + 'last_name' => (string) ($user['last_name'] ?? ''), + 'email' => (string) ($user['email'] ?? ''), + 'username' => (string) ($user['username'] ?? ''), + 'app_support_url' => $config->getSetting(ConfigInterface::APP_SUPPORT_URL, 'https://discord.mythical.systems'), + 'verify_url' => $verifyUrl, + 'uuid' => (string) $user['uuid'], + 'enabled' => $config->getSetting(ConfigInterface::SMTP_ENABLED, 'false'), + ]); + + Cache::put($cacheKey, true, self::RESEND_COOLDOWN_MINUTES); + + return ApiResponse::success([], 'If email verification is needed, a new verification email will be sent.', 200); + } } diff --git a/backend/app/Controllers/User/ChatbotController.php b/backend/app/Controllers/User/ChatbotController.php index 0afc5cecd..58c02443b 100755 --- a/backend/app/Controllers/User/ChatbotController.php +++ b/backend/app/Controllers/User/ChatbotController.php @@ -22,10 +22,12 @@ use App\Helpers\ApiResponse; use OpenApi\Attributes as OA; use App\Chat\ChatConversation; +use App\Services\Chatbot\TokenUsage; use App\Services\Chatbot\ChatbotService; use App\Plugins\Events\Events\ChatbotEvent; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; #[OA\Schema( schema: 'ChatbotRequest', @@ -70,105 +72,28 @@ class ChatbotController )] public function chat(Request $request): Response { - $currentUser = $request->attributes->get('user'); - - if (!$currentUser || !isset($currentUser['id'])) { - return ApiResponse::error('User not authenticated', 'UNAUTHORIZED', 401); - } - - // Check if chatbot is enabled - $app = App::getInstance(true); - $config = $app->getConfig(); - $enabled = $config->getSetting(\App\Config\ConfigInterface::CHATBOT_ENABLED, 'true'); - if ($enabled !== 'true') { - return ApiResponse::error('The AI chatbot is currently disabled by the administrator.', 'CHATBOT_DISABLED', 403); - } - - $data = json_decode($request->getContent(), true); - - if (!isset($data['message']) || empty(trim($data['message']))) { - return ApiResponse::error('Message is required', 'INVALID_REQUEST', 400); - } - - $message = trim($data['message']); - $history = $data['history'] ?? []; - $pageContext = $data['pageContext'] ?? []; - $conversationId = $data['conversation_id'] ?? null; - try { - // Get or create conversation - $conversation = null; - if ($conversationId) { - $conversation = ChatConversation::getConversationById((int) $conversationId); - // Verify conversation belongs to user - if ($conversation && $conversation['user_uuid'] !== $currentUser['uuid']) { - return ApiResponse::error('Conversation not found', 'NOT_FOUND', 404); - } - } - - // Create new conversation if needed - if (!$conversation) { - $conversationId = ChatConversation::createConversation([ - 'user_uuid' => $currentUser['uuid'], - 'title' => substr($message, 0, 255), // Use first message as title - ]); - if (!$conversationId) { - return ApiResponse::error('Failed to create conversation', 'SERVER_ERROR', 500); - } - $conversation = ChatConversation::getConversationById($conversationId); - } - - // Load conversation history from database if not provided - if (empty($history) && $conversation) { - $dbMessages = ChatMessage::getMessagesByConversation($conversation['id'], 50); - $history = array_map(function ($msg) { - return [ - 'role' => $msg['role'], - 'content' => $msg['content'], - ]; - }, $dbMessages); - } - - // Save user message to database - ChatMessage::createMessage([ - 'conversation_id' => $conversation['id'], - 'role' => 'user', - 'content' => $message, - ]); - - // Get conversation memory - $conversationMemory = $conversation['memory'] ?? ''; - $pageContext['conversation_memory'] = $conversationMemory; - - // Process message through AI - $chatbotService = new ChatbotService(); - $result = $chatbotService->processMessage($message, $history, $currentUser, $pageContext); - - // Update message count - $messageCount = ChatMessage::getMessageCount($conversation['id']); - ChatConversation::updateConversation($conversation['id'], [ - 'message_count' => $messageCount, - ]); - - // Save AI response to database - ChatMessage::createMessage([ - 'conversation_id' => $conversation['id'], - 'role' => 'assistant', - 'content' => $result['response'], - 'model' => $result['model'] ?? null, - ]); - - // Update conversation timestamp - ChatConversation::updateConversation($conversation['id'], [ - 'updated_at' => date('Y-m-d H:i:s'), - ]); + $payload = $this->processChatRequest($request); return ApiResponse::success([ - 'response' => $result['response'], - 'model' => $result['model'] ?? 'FeatherPanel AI', - 'conversation_id' => $conversation['id'], - 'tool_executions' => $result['tool_executions'] ?? [], // Include tool execution results + 'response' => $payload['response'], + 'model' => $payload['model'], + 'conversation_id' => $payload['conversation_id'], + 'message_id' => $payload['assistant_message_id'], + 'user_message_id' => $payload['user_message_id'], + 'usage' => $payload['usage'], + 'user_usage' => $payload['user_usage'], + 'tool_executions' => $payload['tool_executions'], + 'tool_activity' => $payload['tool_activity'], ], 'Message processed successfully'); + } catch (\InvalidArgumentException $e) { + return ApiResponse::error($e->getMessage(), 'INVALID_REQUEST', 400); + } catch (\RuntimeException $e) { + $message = $e->getMessage(); + $code = $message === 'User not authenticated' ? 401 : ($message === 'Conversation not found' ? 404 : (str_contains($message, 'disabled') ? 403 : 500)); + $errorCode = $code === 401 ? 'UNAUTHORIZED' : ($code === 404 ? 'NOT_FOUND' : ($code === 403 ? 'CHATBOT_DISABLED' : 'CHATBOT_ERROR')); + + return ApiResponse::error($message, $errorCode, $code); } catch (\Exception $e) { App::getInstance(true)->getLogger()->error('Chatbot error: ' . $e->getMessage()); @@ -180,6 +105,32 @@ public function chat(Request $request): Response } } + public function streamChat(Request $request): Response + { + return new StreamedResponse(function () use ($request): void { + $emit = function (string $type, array $payload = []): void { + echo "event: {$type}\n"; + echo 'data: ' . json_encode($payload, JSON_UNESCAPED_SLASHES) . "\n\n"; + @ob_flush(); + flush(); + }; + + try { + $payload = $this->processChatRequest($request, $emit); + $emit('final', $payload); + } catch (\Exception $e) { + App::getInstance(true)->getLogger()->error('Chatbot stream error: ' . $e->getMessage()); + $emit('error', [ + 'message' => 'Failed to process message. Please try again.', + ]); + } + }, 200, [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'X-Accel-Buffering' => 'no', + ]); + } + #[OA\Get( path: '/api/user/chatbot/conversations', summary: 'Get user conversations', @@ -414,6 +365,192 @@ public function updateMemory(Request $request, int $id): Response } } + private function processChatRequest(Request $request, ?callable $emit = null): array + { + $currentUser = $request->attributes->get('user'); + + if (!$currentUser || !isset($currentUser['id'])) { + throw new \RuntimeException('User not authenticated'); + } + + $app = App::getInstance(true); + $config = $app->getConfig(); + $enabled = $config->getSetting(\App\Config\ConfigInterface::CHATBOT_ENABLED, 'true'); + if ($enabled !== 'true') { + throw new \RuntimeException('The AI chatbot is currently disabled by the administrator.'); + } + + $data = json_decode($request->getContent(), true) ?: []; + if (!isset($data['message']) || empty(trim($data['message']))) { + throw new \InvalidArgumentException('Message is required'); + } + + $message = trim($data['message']); + $history = $data['history'] ?? []; + $pageContext = $data['pageContext'] ?? []; + $conversationId = $data['conversation_id'] ?? null; + + $conversation = null; + if ($conversationId) { + $conversation = ChatConversation::getConversationById((int) $conversationId); + if ($conversation && $conversation['user_uuid'] !== $currentUser['uuid']) { + throw new \RuntimeException('Conversation not found'); + } + } + + if (!$conversation) { + $conversationId = ChatConversation::createConversation([ + 'user_uuid' => $currentUser['uuid'], + 'title' => substr($message, 0, 255), + ]); + if (!$conversationId) { + throw new \RuntimeException('Failed to create conversation'); + } + $conversation = ChatConversation::getConversationById($conversationId); + } + + $dbMessages = ChatMessage::getMessagesByConversation($conversation['id'], 50); + if (empty($history) && $conversation) { + $history = $this->buildCompactHistory($dbMessages, $conversation); + } else { + $history = array_slice($history, -6); + } + + $userUsage = TokenUsage::estimate($message); + $userMessageId = ChatMessage::createMessage([ + 'conversation_id' => $conversation['id'], + 'role' => 'user', + 'content' => $message, + 'input_tokens' => $userUsage['input_tokens'], + 'total_tokens' => $userUsage['total_tokens'], + 'token_source' => $userUsage['source'], + ]); + + if ($emit !== null) { + $emit('conversation', [ + 'conversation_id' => $conversation['id'], + 'user_message_id' => $userMessageId, + 'user_usage' => $userUsage, + ]); + } + + $summary = $this->refreshContextSummary($conversation, $dbMessages, $message); + if ($emit !== null && $summary !== '') { + $emit('status', ['message' => 'Compacting conversation context']); + } + $pageContext['conversation_memory'] = trim(($conversation['memory'] ?? '') . "\n\n" . $summary); + + $chatbotService = new ChatbotService(); + $result = $chatbotService->processMessage($message, $history, $currentUser, $pageContext, $emit); + $usage = $result['usage'] ?? TokenUsage::estimate($message, $result['response'] ?? ''); + $toolActivity = $result['tool_activity'] ?? []; + + $assistantMessageId = ChatMessage::createMessage([ + 'conversation_id' => $conversation['id'], + 'role' => 'assistant', + 'content' => $result['response'], + 'model' => $result['model'] ?? null, + 'input_tokens' => $usage['input_tokens'] ?? null, + 'output_tokens' => $usage['output_tokens'] ?? null, + 'total_tokens' => $usage['total_tokens'] ?? null, + 'token_source' => $usage['source'] ?? null, + 'tool_activity' => $toolActivity, + 'usage_json' => $usage, + ]); + + $messageCount = ChatMessage::getMessageCount($conversation['id']); + ChatConversation::updateConversation($conversation['id'], [ + 'message_count' => $messageCount, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + return [ + 'response' => $result['response'], + 'model' => $result['model'] ?? 'FeatherPanel AI', + 'conversation_id' => $conversation['id'], + 'user_message_id' => $userMessageId, + 'assistant_message_id' => $assistantMessageId, + 'usage' => $usage, + 'user_usage' => $userUsage, + 'tool_executions' => $result['tool_executions'] ?? [], + 'tool_activity' => $toolActivity, + ]; + } + + private function buildCompactHistory(array $dbMessages, array $conversation): array + { + $summary = $conversation['context_summary'] ?? ''; + $recentMessages = array_slice($dbMessages, -6); + $history = []; + + if ($summary !== '') { + $history[] = [ + 'role' => 'assistant', + 'content' => "Conversation context summary:\n{$summary}", + ]; + } + + foreach ($recentMessages as $msg) { + $content = $msg['content']; + if (!empty($msg['tool_activity']) && is_array($msg['tool_activity'])) { + $content .= "\n\n[Tool/activity results from this assistant turn]\n" . $this->formatToolActivityForHistory($msg['tool_activity']); + } + $history[] = [ + 'role' => $msg['role'], + 'content' => $content, + ]; + } + + return $history; + } + + private function refreshContextSummary(array $conversation, array $dbMessages, string $latestMessage): string + { + $existing = trim((string) ($conversation['context_summary'] ?? '')); + if (count($dbMessages) < 10 || !ChatConversation::hasColumn('context_summary')) { + return $existing; + } + + $olderMessages = array_slice($dbMessages, 0, -6); + $lines = []; + foreach ($olderMessages as $message) { + $content = trim(preg_replace('/\s+/', ' ', (string) $message['content'])); + if (!empty($message['tool_activity']) && is_array($message['tool_activity'])) { + $content .= ' Tool/activity: ' . preg_replace('/\s+/', ' ', $this->formatToolActivityForHistory($message['tool_activity'])); + } + if ($content === '') { + continue; + } + $lines[] = strtoupper((string) $message['role']) . ': ' . mb_substr($content, 0, 220); + } + + $summary = trim($existing . "\n" . implode("\n", array_slice($lines, -12))); + $summary = mb_substr($summary, -3500); + $summary .= "\nLatest user message: " . mb_substr($latestMessage, 0, 300); + + ChatConversation::updateConversation((int) $conversation['id'], [ + 'context_summary' => $summary, + 'context_summary_updated_at' => date('Y-m-d H:i:s'), + ]); + + return $summary; + } + + private function formatToolActivityForHistory(array $toolActivity): string + { + $lines = []; + foreach ($toolActivity as $activity) { + if (!is_array($activity)) { + continue; + } + $status = ($activity['success'] ?? null) === false ? 'failed' : (($activity['success'] ?? null) ? 'completed' : 'planned'); + $summary = isset($activity['summary']) ? ' - ' . $activity['summary'] : ''; + $lines[] = ($activity['tool'] ?? 'unknown_tool') . ": {$status}{$summary}"; + } + + return implode("\n", $lines); + } + private static function emitEvent(string $eventName, array $payload): void { global $eventManager; diff --git a/backend/app/Controllers/User/NodeStatusController.php b/backend/app/Controllers/User/NodeStatusController.php index fddcd6d6a..353e0c467 100755 --- a/backend/app/Controllers/User/NodeStatusController.php +++ b/backend/app/Controllers/User/NodeStatusController.php @@ -75,12 +75,18 @@ public function getStatus(Request $request): Response $allowIframe = $config->getSetting(ConfigInterface::STATUS_PAGE_ALLOW_IFRAME, 'false') === 'true'; $showRawValues = $config->getSetting(ConfigInterface::STATUS_PAGE_SHOW_RAW_VALUES, 'false') === 'true'; $showPlayerCount = $config->getSetting(ConfigInterface::STATUS_PAGE_SHOW_PLAYER_COUNT, 'false') === 'true'; + $showPoweredBy = $config->getSetting(ConfigInterface::BRANDING_SHOW_POWERED_BY, 'true') === 'true'; $responseData = [ 'enabled' => true, 'allow_iframe' => $allowIframe, 'show_raw_values' => $showRawValues, 'show_player_count' => $showPlayerCount, + 'powered_by' => [ + 'show' => $showPoweredBy, + 'label' => 'FeatherPanel', + 'url' => 'https://featherpanel.com', + ], ]; // Get node status if enabled diff --git a/backend/app/Controllers/User/Server/Files/ServerFilesController.php b/backend/app/Controllers/User/Server/Files/ServerFilesController.php index e562325cc..140f5ae02 100755 --- a/backend/app/Controllers/User/Server/Files/ServerFilesController.php +++ b/backend/app/Controllers/User/Server/Files/ServerFilesController.php @@ -24,6 +24,7 @@ use App\Helpers\ApiResponse; use App\Services\Wings\Wings; use OpenApi\Attributes as OA; +use App\Config\ConfigInterface; use App\Plugins\Events\Events\ServerEvent; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -36,7 +37,8 @@ properties: [ new OA\Property(property: 'name', type: 'string', description: 'File or directory name'), new OA\Property(property: 'type', type: 'string', enum: ['file', 'directory'], description: 'Item type'), - new OA\Property(property: 'size', type: 'integer', nullable: true, description: 'File size in bytes'), + new OA\Property(property: 'size', type: 'integer', nullable: true, description: 'File size in bytes (directory entry size for folders)'), + new OA\Property(property: 'directory_size', type: 'integer', nullable: true, description: 'Recursive size of folder contents in bytes (only when requested from Wings)'), new OA\Property(property: 'permissions', type: 'string', nullable: true, description: 'File permissions'), new OA\Property(property: 'modified_at', type: 'string', format: 'date-time', nullable: true, description: 'Last modified timestamp'), new OA\Property(property: 'path', type: 'string', description: 'Full file path'), @@ -184,7 +186,7 @@ public function getFiles(Request $request, string $serverUuid): Response $path = $this->getPathFromQuery(); $wings = $this->createWingsConnection($node); - $response = $wings->getServer()->listDirectory($server['uuid'], $path); + $response = $wings->getServer()->listDirectory($server['uuid'], $path, true); if (!$response->isSuccessful()) { $error = $response->getError(); @@ -222,6 +224,69 @@ public function getFiles(Request $request, string $serverUuid): Response } } + #[OA\Get( + path: '/api/user/servers/{uuidShort}/archive-list', + summary: 'List directory inside an archive', + description: 'Browse supported archives (zip, tar, …) on the server without extracting them.', + tags: ['User - Server Files'], + parameters: [ + new OA\Parameter(name: 'uuidShort', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'path', in: 'query', required: false, schema: new OA\Schema(type: 'string', default: '/')), + new OA\Parameter(name: 'file', in: 'query', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'archive_path', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Archive listing retrieved'), + new OA\Response(response: 400, description: 'Bad request'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 500, description: 'Internal server error'), + ] + )] + public function listArchiveDirectory(Request $request, string $serverUuid): Response + { + try { + $user = $this->validateUser($request); + $server = $this->validateServer($serverUuid); + $node = $this->validateNode($server['node_id']); + + $permissionCheck = $this->checkPermission($request, $server, SubuserPermissions::FILE_READ); + if ($permissionCheck !== null) { + return $permissionCheck; + } + + $directory = $this->getPathFromQuery('/'); + $file = $_GET['file'] ?? ''; + if ($file === '' || !is_string($file)) { + return ApiResponse::error('Missing file parameter (archive path relative to path).', 'MISSING_FILE', 400); + } + $innerPath = isset($_GET['archive_path']) && is_string($_GET['archive_path']) ? $_GET['archive_path'] : ''; + + $wings = $this->createWingsConnection($node); + $response = $wings->getServer()->listArchiveDirectory($server['uuid'], $directory, $file, $innerPath); + if (!$response->isSuccessful()) { + $error = $response->getError(); + if ($this->isWingsConnectionUnavailableError($error)) { + return ApiResponse::error( + 'Wings Connection Unavailable. Please contact the support team. Technical details: ' . $error, + 'WINGS_CONNECTION_UNAVAILABLE', + 503 + ); + } + + return ApiResponse::error('Failed to list archive: ' . $error, 'WINGS_ERROR', $response->getStatusCode()); + } + + $data = $response->getData(); + if (!is_array($data)) { + $data = ['contents' => [], 'truncated' => false]; + } + + return ApiResponse::success($data, 'Archive listing retrieved successfully'); + } catch (\Exception $e) { + return $this->handleWingsError($e, 'list archive'); + } + } + #[OA\Get( path: '/api/user/servers/{uuidShort}/search-files', summary: 'Search files with advanced filters', @@ -738,9 +803,11 @@ public function deleteFiles(Request $request, string $serverUuid): Response } $data = $this->validateJsonBody($request, ['files', 'root']); + $permanent = !empty($data['permanent']); + $deleteOptions = $this->buildTrashDeleteOptions($permanent); $wings = $this->createWingsConnection($node); - $response = $wings->getServer()->deleteFiles($server['uuid'], $data['root'], $data['files']); + $response = $wings->getServer()->deleteFiles($server['uuid'], $data['root'], $data['files'], $deleteOptions); if (!$response->isSuccessful()) { $error = $response->getError(); @@ -748,12 +815,15 @@ public function deleteFiles(Request $request, string $serverUuid): Response return ApiResponse::error('Failed to delete files: ' . $error, 'WINGS_ERROR', $response->getStatusCode()); } + $usedTrash = !empty($deleteOptions['use_trash']) && empty($deleteOptions['permanent']); + // Log activity - $this->logActivity($server, $node, 'files_deleted', [ + $this->logActivity($server, $node, $usedTrash ? 'files_trashed' : 'files_deleted', [ 'root' => $data['root'], 'files' => $data['files'], 'file_count' => count($data['files']), + 'trash' => $usedTrash, ], $user); // Emit event @@ -768,12 +838,152 @@ public function deleteFiles(Request $request, string $serverUuid): Response ); } - return ApiResponse::success($response->getData(), 'Files deleted successfully'); + $message = $usedTrash ? 'Files moved to trash successfully' : 'Files deleted successfully'; + + return ApiResponse::success($response->getData(), $message); } catch (\Exception $e) { return $this->handleWingsError($e, 'delete files'); } } + public function listTrash(Request $request, string $serverUuid): Response + { + try { + $user = $this->validateUser($request); + $server = $this->validateServer($serverUuid); + $node = $this->validateNode($server['node_id']); + + $permissionCheck = $this->checkPermission($request, $server, SubuserPermissions::FILE_READ); + if ($permissionCheck !== null) { + return $permissionCheck; + } + + if (!$this->isFileTrashEnabled()) { + return ApiResponse::error('File trash is disabled on this panel', 'TRASH_DISABLED', 403); + } + + $limits = $this->getTrashLimits(); + $wings = $this->createWingsConnection($node); + $response = $wings->getServer()->listTrash( + $server['uuid'], + (int) $limits['max_size_bytes'], + (int) $limits['retention_days'] + ); + + if (!$response->isSuccessful()) { + return ApiResponse::error('Failed to list trash: ' . $response->getError(), 'WINGS_ERROR', $response->getStatusCode()); + } + + $this->logActivity($server, $node, 'trash_listed', [], $user); + + return ApiResponse::success($response->getData(), 'Trash listed successfully'); + } catch (\Exception $e) { + return $this->handleWingsError($e, 'list trash'); + } + } + + public function restoreTrash(Request $request, string $serverUuid): Response + { + try { + $user = $this->validateUser($request); + $server = $this->validateServer($serverUuid); + $node = $this->validateNode($server['node_id']); + + $permissionCheck = $this->checkPermission($request, $server, SubuserPermissions::FILE_UPDATE); + if ($permissionCheck !== null) { + return $permissionCheck; + } + + if (!$this->isFileTrashEnabled()) { + return ApiResponse::error('File trash is disabled on this panel', 'TRASH_DISABLED', 403); + } + + $data = $this->validateJsonBody($request, ['ids']); + $overwrite = isset($data['overwrite']) && (bool) $data['overwrite']; + $wings = $this->createWingsConnection($node); + $response = $wings->getServer()->restoreTrash($server['uuid'], $data['ids'], $overwrite); + + if (!$response->isSuccessful()) { + return ApiResponse::error('Failed to restore files: ' . $response->getError(), 'WINGS_ERROR', $response->getStatusCode()); + } + + $this->logActivity($server, $node, 'trash_restored', [ + 'ids' => $data['ids'], + 'count' => count($data['ids']), + ], $user); + + return ApiResponse::success($response->getData(), 'Files restored successfully'); + } catch (\Exception $e) { + return $this->handleWingsError($e, 'restore trash'); + } + } + + public function deleteTrashEntries(Request $request, string $serverUuid): Response + { + try { + $user = $this->validateUser($request); + $server = $this->validateServer($serverUuid); + $node = $this->validateNode($server['node_id']); + + $permissionCheck = $this->checkPermission($request, $server, SubuserPermissions::FILE_DELETE); + if ($permissionCheck !== null) { + return $permissionCheck; + } + + if (!$this->isFileTrashEnabled()) { + return ApiResponse::error('File trash is disabled on this panel', 'TRASH_DISABLED', 403); + } + + $data = $this->validateJsonBody($request, ['ids']); + $wings = $this->createWingsConnection($node); + $response = $wings->getServer()->deleteTrashEntries($server['uuid'], $data['ids']); + + if (!$response->isSuccessful()) { + return ApiResponse::error('Failed to delete trash entries: ' . $response->getError(), 'WINGS_ERROR', $response->getStatusCode()); + } + + $this->logActivity($server, $node, 'trash_deleted', [ + 'ids' => $data['ids'], + 'count' => count($data['ids']), + ], $user); + + return ApiResponse::success($response->getData(), 'Trash entries deleted permanently'); + } catch (\Exception $e) { + return $this->handleWingsError($e, 'delete trash entries'); + } + } + + public function emptyTrash(Request $request, string $serverUuid): Response + { + try { + $user = $this->validateUser($request); + $server = $this->validateServer($serverUuid); + $node = $this->validateNode($server['node_id']); + + $permissionCheck = $this->checkPermission($request, $server, SubuserPermissions::FILE_DELETE); + if ($permissionCheck !== null) { + return $permissionCheck; + } + + if (!$this->isFileTrashEnabled()) { + return ApiResponse::error('File trash is disabled on this panel', 'TRASH_DISABLED', 403); + } + + $wings = $this->createWingsConnection($node); + $response = $wings->getServer()->emptyTrash($server['uuid']); + + if (!$response->isSuccessful()) { + return ApiResponse::error('Failed to empty trash: ' . $response->getError(), 'WINGS_ERROR', $response->getStatusCode()); + } + + $this->logActivity($server, $node, 'trash_emptied', [], $user); + + return ApiResponse::success($response->getData(), 'Trash emptied successfully'); + } catch (\Exception $e) { + return $this->handleWingsError($e, 'empty trash'); + } + } + #[OA\Post( path: '/api/user/servers/{uuidShort}/wipe-all-files', summary: 'Wipe all server files', @@ -1333,6 +1543,123 @@ public function decompressArchive(Request $request, string $serverUuid): Respons } } + #[OA\Post( + path: '/api/user/servers/{uuidShort}/extract-archive-selection', + summary: 'Extract selected paths from an archive', + description: 'Extract specific files or directories from an on-disk archive into a destination folder without unpacking the whole archive.', + tags: ['User - Server Files'], + parameters: [ + new OA\Parameter( + name: 'uuidShort', + in: 'path', + description: 'Server short UUID', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['root', 'file', 'destination', 'entries'], + properties: [ + new OA\Property(property: 'root', type: 'string', description: 'Directory on the server that contains the archive'), + new OA\Property(property: 'file', type: 'string', description: 'Archive file name relative to root'), + new OA\Property( + property: 'destination', + type: 'string', + description: 'Destination directory relative to server root (use empty string for /)' + ), + new OA\Property( + property: 'entries', + type: 'array', + items: new OA\Items(type: 'string'), + description: 'Paths inside the archive to extract' + ), + ] + ) + ), + responses: [ + new OA\Response(response: 200, description: 'Extraction started or completed successfully'), + new OA\Response(response: 400, description: 'Bad request'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 500, description: 'Internal server error'), + ] + )] + public function extractArchiveSelection(Request $request, string $serverUuid): Response + { + try { + $user = $this->validateUser($request); + $server = $this->validateServer($serverUuid); + $node = $this->validateNode($server['node_id']); + + $permissionCheck = $this->checkPermission($request, $server, SubuserPermissions::FILE_ARCHIVE); + if ($permissionCheck !== null) { + return $permissionCheck; + } + + $data = $this->validateJsonBody($request, ['root', 'file', 'destination', 'entries']); + if (!is_array($data['entries'])) { + return ApiResponse::error('Field entries must be an array of strings.', 'INVALID_ENTRIES', 400); + } + $entries = []; + foreach ($data['entries'] as $entry) { + if (is_string($entry) && $entry !== '') { + $entries[] = $entry; + } + } + if ($entries === []) { + return ApiResponse::error('At least one non-empty archive entry path is required.', 'INVALID_ENTRIES', 400); + } + + $root = $this->normalizeDirectoryPath((string) $data['root']); + $file = (string) $data['file']; + + $destRaw = $data['destination']; + if (!is_string($destRaw)) { + return ApiResponse::error('Field destination must be a string.', 'INVALID_DESTINATION', 400); + } + $destination = ($destRaw === '' || $destRaw === '/') ? '' : ltrim($this->normalizeDirectoryPath($destRaw), '/'); + + $wings = $this->createWingsConnection($node); + $response = $wings->getServer()->extractArchiveSelection($server['uuid'], $root, $file, $destination, $entries); + + if (!$response->isSuccessful()) { + $error = $response->getError(); + if (strpos(strtolower($error), 'timeout') !== false || strpos(strtolower($error), 'timed out') !== false) { + return ApiResponse::error( + 'Archive extraction timed out. Try a smaller selection or extract the full archive instead.', + 'ARCHIVE_TIMEOUT', + $response->getStatusCode() + ); + } + + return ApiResponse::error('Failed to extract from archive: ' . $error, 'WINGS_ERROR', $response->getStatusCode()); + } + + $this->logActivity($server, $node, 'archive_selection_extracted', [ + 'root' => $root, + 'file' => $file, + 'destination' => $destination, + 'entry_count' => count($entries), + ], $user); + + return ApiResponse::success($response->getData(), 'Archive selection extracted successfully'); + } catch (\App\Services\Wings\Exceptions\WingsConnectionException $e) { + $errorMessage = $e->getMessage(); + if (strpos(strtolower($errorMessage), 'timeout') !== false || strpos(strtolower($errorMessage), 'timed out') !== false) { + return ApiResponse::error( + 'Archive extraction timed out. Try a smaller selection or extract the full archive instead.', + 'ARCHIVE_TIMEOUT', + 504 + ); + } + + return $this->handleWingsError($e, 'extract archive selection'); + } catch (\Exception $e) { + return $this->handleWingsError($e, 'extract archive selection'); + } + } + #[OA\Post( path: '/api/user/servers/{uuidShort}/change-permissions', summary: 'Change file permissions', @@ -2222,4 +2549,42 @@ private function getMimeType(string $path): string return $mimeTypes[$extension] ?? 'text/plain'; } + + private function isFileTrashEnabled(): bool + { + return App::getInstance(true)->getConfig()->getSetting(ConfigInterface::FILE_TRASH_ENABLED, 'false') === 'true'; + } + + /** + * @return array{max_size_bytes: int, retention_days: int} + */ + private function getTrashLimits(): array + { + $config = App::getInstance(true)->getConfig(); + $maxMb = (int) $config->getSetting(ConfigInterface::FILE_TRASH_MAX_SIZE_MB, '512'); + $retentionDays = (int) $config->getSetting(ConfigInterface::FILE_TRASH_RETENTION_DAYS, '30'); + + return [ + 'max_size_bytes' => $maxMb > 0 ? $maxMb * 1024 * 1024 : 0, + 'retention_days' => $retentionDays, + ]; + } + + /** + * @return array + */ + private function buildTrashDeleteOptions(bool $permanent): array + { + if ($permanent || !$this->isFileTrashEnabled()) { + return ['permanent' => true]; + } + + $limits = $this->getTrashLimits(); + + return [ + 'use_trash' => true, + 'permanent' => false, + 'trash' => $limits, + ]; + } } diff --git a/backend/app/Controllers/User/Server/ServerActivityController.php b/backend/app/Controllers/User/Server/ServerActivityController.php index e94e86a6d..efbff7aed 100755 --- a/backend/app/Controllers/User/Server/ServerActivityController.php +++ b/backend/app/Controllers/User/Server/ServerActivityController.php @@ -20,6 +20,7 @@ use App\App; use App\Chat\User; use App\Chat\Server; +use App\Helpers\TimeHelper; use App\SubuserPermissions; use App\Chat\ServerActivity; use App\Helpers\ApiResponse; @@ -171,11 +172,12 @@ public function getServerActivities(Request $request, int $serverId): Response // Mask IPs if the setting is enabled $app = App::getInstance(true); $hideIps = $app->getConfig()->getSetting(ConfigInterface::SERVER_HIDE_IPS, 'false') === 'true'; - if ($hideIps && isset($result['data'])) { + if (isset($result['data'])) { foreach ($result['data'] as &$activity) { - if (!empty($activity['ip'])) { + if ($hideIps && !empty($activity['ip'])) { $activity['ip'] = '***.***.***.***'; } + $activity = TimeHelper::normaliseRow($activity, ['timestamp']); } unset($activity); } diff --git a/backend/app/Controllers/User/Server/ServerBackupController.php b/backend/app/Controllers/User/Server/ServerBackupController.php index 8ad06d04f..22342ad90 100755 --- a/backend/app/Controllers/User/Server/ServerBackupController.php +++ b/backend/app/Controllers/User/Server/ServerBackupController.php @@ -355,12 +355,6 @@ public function createBackup(Request $request, string $serverUuid): Response } } - // Parse request body - $body = json_decode($request->getContent(), true); - if (!$body) { - return ApiResponse::error('Invalid request body', 'INVALID_REQUEST_BODY', 400); - } - // Always use Wings adapter $adapter = 'wings'; diff --git a/backend/app/Controllers/User/Server/ServerScheduleController.php b/backend/app/Controllers/User/Server/ServerScheduleController.php index 0dfcc12df..f15f5198b 100755 --- a/backend/app/Controllers/User/Server/ServerScheduleController.php +++ b/backend/app/Controllers/User/Server/ServerScheduleController.php @@ -44,6 +44,7 @@ new OA\Property(property: 'cron_day_of_month', type: 'string', description: 'Cron day of month expression'), new OA\Property(property: 'cron_hour', type: 'string', description: 'Cron hour expression'), new OA\Property(property: 'cron_minute', type: 'string', description: 'Cron minute expression'), + new OA\Property(property: 'timezone', type: 'string', description: 'IANA timezone the cron expression is authored in (e.g. "Europe/Paris", "UTC")'), new OA\Property(property: 'is_active', type: 'boolean', description: 'Whether schedule is active'), new OA\Property(property: 'is_processing', type: 'boolean', description: 'Whether schedule is currently processing'), new OA\Property(property: 'only_when_online', type: 'boolean', description: 'Whether to run only when server is online'), @@ -75,6 +76,7 @@ new OA\Property(property: 'cron_day_of_month', type: 'string', description: 'Cron day of month expression'), new OA\Property(property: 'cron_hour', type: 'string', description: 'Cron hour expression'), new OA\Property(property: 'cron_minute', type: 'string', description: 'Cron minute expression'), + new OA\Property(property: 'timezone', type: 'string', nullable: true, description: 'IANA timezone the cron expression is authored in (default: "UTC")', default: 'UTC'), new OA\Property(property: 'is_active', type: 'boolean', nullable: true, description: 'Whether schedule is active', default: true), new OA\Property(property: 'only_when_online', type: 'boolean', nullable: true, description: 'Whether to run only when server is online', default: false), ] @@ -98,6 +100,7 @@ new OA\Property(property: 'cron_day_of_month', type: 'string', nullable: true, description: 'Cron day of month expression'), new OA\Property(property: 'cron_hour', type: 'string', nullable: true, description: 'Cron hour expression'), new OA\Property(property: 'cron_minute', type: 'string', nullable: true, description: 'Cron minute expression'), + new OA\Property(property: 'timezone', type: 'string', nullable: true, description: 'IANA timezone the cron expression is authored in'), new OA\Property(property: 'is_active', type: 'boolean', nullable: true, description: 'Whether schedule is active'), new OA\Property(property: 'only_when_online', type: 'boolean', nullable: true, description: 'Whether to run only when server is online'), ] @@ -371,13 +374,23 @@ public function createSchedule(Request $request, string $serverUuid): Response return ApiResponse::error('Invalid cron expression', 'INVALID_CRON_EXPRESSION', 400); } - // Calculate next run time + // Resolve & validate timezone (default UTC for backwards compat). + $timezone = isset($body['timezone']) && is_string($body['timezone']) && $body['timezone'] !== '' + ? $body['timezone'] + : 'UTC'; + if (!ServerSchedule::isValidTimezone($timezone)) { + return ApiResponse::error('Invalid timezone identifier', 'INVALID_TIMEZONE', 400); + } + + // Calculate next run time in the schedule's authoring timezone, persisted as UTC. $nextRunAt = ServerSchedule::calculateNextRunTime( $body['cron_day_of_week'], $body['cron_month'], $body['cron_day_of_month'], $body['cron_hour'], - $body['cron_minute'] + $body['cron_minute'], + null, + $timezone ); // Create schedule data @@ -389,6 +402,7 @@ public function createSchedule(Request $request, string $serverUuid): Response 'cron_day_of_month' => $body['cron_day_of_month'], 'cron_hour' => $body['cron_hour'], 'cron_minute' => $body['cron_minute'], + 'timezone' => $timezone, 'is_active' => $body['is_active'] ?? 1, 'is_processing' => 0, 'only_when_online' => $body['only_when_online'] ?? 0, @@ -504,20 +518,28 @@ public function updateSchedule(Request $request, string $serverUuid, int $schedu return ApiResponse::error('Invalid request body', 'INVALID_REQUEST_BODY', 400); } + // If a timezone was supplied, validate it up-front (and recompute next_run_at below). + if (isset($body['timezone'])) { + if (!is_string($body['timezone']) || !ServerSchedule::isValidTimezone($body['timezone'])) { + return ApiResponse::error('Invalid timezone identifier', 'INVALID_TIMEZONE', 400); + } + } + // Validate cron expression components if provided - if (isset($body['cron_day_of_week']) || isset($body['cron_month']) || isset($body['cron_day_of_month']) || isset($body['cron_hour']) || isset($body['cron_minute'])) { + if (isset($body['cron_day_of_week']) || isset($body['cron_month']) || isset($body['cron_day_of_month']) || isset($body['cron_hour']) || isset($body['cron_minute']) || isset($body['timezone'])) { $dayOfWeek = $body['cron_day_of_week'] ?? $schedule['cron_day_of_week']; $month = $body['cron_month'] ?? $schedule['cron_month']; $dayOfMonth = $body['cron_day_of_month'] ?? $schedule['cron_day_of_month']; $hour = $body['cron_hour'] ?? $schedule['cron_hour']; $minute = $body['cron_minute'] ?? $schedule['cron_minute']; + $timezone = $body['timezone'] ?? ($schedule['timezone'] ?? 'UTC'); if (!ServerSchedule::validateCronExpression($dayOfWeek, $month, $dayOfMonth, $hour, $minute)) { return ApiResponse::error('Invalid cron expression', 'INVALID_CRON_EXPRESSION', 400); } - // Calculate new next run time if cron expression changed - $body['next_run_at'] = ServerSchedule::calculateNextRunTime($dayOfWeek, $month, $dayOfMonth, $hour, $minute); + // Recompute next_run_at in the (possibly new) timezone, persist as UTC. + $body['next_run_at'] = ServerSchedule::calculateNextRunTime($dayOfWeek, $month, $dayOfMonth, $hour, $minute, null, $timezone); } // Update schedule @@ -1092,6 +1114,7 @@ public function exportSchedule(Request $request, string $serverUuid, int $schedu 'cron_day_of_month' => $schedule['cron_day_of_month'], 'cron_month' => $schedule['cron_month'], 'cron_day_of_week' => $schedule['cron_day_of_week'], + 'timezone' => $schedule['timezone'] ?? 'UTC', 'is_active' => (bool) $schedule['is_active'], 'only_when_online' => (bool) $schedule['only_when_online'], 'tasks' => $exportedTasks, @@ -1206,12 +1229,21 @@ public function importSchedule(Request $request, string $serverUuid): Response } } + $timezone = isset($body['timezone']) && is_string($body['timezone']) && $body['timezone'] !== '' + ? $body['timezone'] + : 'UTC'; + if (!ServerSchedule::isValidTimezone($timezone)) { + return ApiResponse::error('Invalid timezone identifier', 'INVALID_TIMEZONE', 400); + } + $nextRunAt = ServerSchedule::calculateNextRunTime( $body['cron_day_of_week'], $body['cron_month'], $body['cron_day_of_month'], $body['cron_hour'], - $body['cron_minute'] + $body['cron_minute'], + null, + $timezone ); $scheduleId = ServerSchedule::createSchedule([ @@ -1222,6 +1254,7 @@ public function importSchedule(Request $request, string $serverUuid): Response 'cron_day_of_month' => $body['cron_day_of_month'], 'cron_hour' => $body['cron_hour'], 'cron_minute' => $body['cron_minute'], + 'timezone' => $timezone, 'is_active' => isset($body['is_active']) ? (int) $body['is_active'] : 1, 'is_processing' => 0, 'only_when_online' => isset($body['only_when_online']) ? (int) $body['only_when_online'] : 0, diff --git a/backend/app/Controllers/User/Server/ServerUserController.php b/backend/app/Controllers/User/Server/ServerUserController.php index 815db6802..73887d550 100755 --- a/backend/app/Controllers/User/Server/ServerUserController.php +++ b/backend/app/Controllers/User/Server/ServerUserController.php @@ -38,6 +38,7 @@ use App\Chat\DatabaseInstance; use App\Config\ConfigInterface; use App\Helpers\PermissionHelper; +use App\Chat\ServerCustomVariable; use App\CloudFlare\CloudFlareRealIP; use App\Mail\templates\ServerDeleted; use App\Plugins\Events\Events\ServerEvent; @@ -754,6 +755,7 @@ public function getServer(Request $request, string $uuidShort): Response } $server['variables'] = $mergedVariables; + $server['custom_variables'] = ServerCustomVariable::getCustomVariablesByServerId((int) $server['id']); // Start flatten specific fields if they are valid JSON, else leave as is (do not json_decode, just keep string if not) $spell = &$server['spell']; @@ -1470,6 +1472,136 @@ public function updateServer(Request $request, string $uuidShort): Response ], 'Server updated successfully', 200); } + public function createCustomVariable(Request $request, string $uuidShort): Response + { + $user = $request->attributes->get('user'); + if (!$user) { + return ApiResponse::error('User not authenticated', 'UNAUTHORIZED', 401); + } + + $server = Server::getServerByUuidShort($uuidShort); + if (!$server) { + return ApiResponse::error('Server not found', 'NOT_FOUND', 404); + } + + $isOwner = (int) $server['owner_id'] === (int) $user['id']; + if (!$isOwner) { + $permissionCheck = $this->checkPermission($request, $server, SubuserPermissions::STARTUP_UPDATE); + if ($permissionCheck !== null) { + return $permissionCheck; + } + } + + $data = json_decode($request->getContent(), true); + if (!is_array($data)) { + return ApiResponse::error('Invalid request data', 'INVALID_REQUEST', 400); + } + + $name = trim((string) ($data['name'] ?? '')); + $envVariable = strtoupper(trim((string) ($data['env_variable'] ?? ''))); + $value = (string) ($data['variable_value'] ?? ''); + $isEncrypted = isset($data['is_encrypted']) && filter_var($data['is_encrypted'], FILTER_VALIDATE_BOOLEAN); + + if ($name === '' || strlen($name) > 191) { + return ApiResponse::error('Variable name is required and must be less than 191 characters', 'INVALID_NAME', 400); + } + + if (!ServerCustomVariable::isValidEnvVariable($envVariable) || strlen($envVariable) > 191) { + return ApiResponse::error('Environment variable must use only uppercase letters, numbers, and underscores, and cannot start with a number', 'INVALID_ENV_VARIABLE', 400); + } + + $reservedVariables = [ + 'P_SERVER_LOCATION', + 'P_SERVER_UUID', + 'P_SERVER_ALLOCATION_LIMIT', + 'SERVER_MEMORY', + 'SERVER_IP', + 'SERVER_PORT', + ]; + if (in_array($envVariable, $reservedVariables, true) || str_starts_with($envVariable, 'P_SERVER_')) { + return ApiResponse::error('This environment variable name is reserved', 'RESERVED_ENV_VARIABLE', 400); + } + + if (ServerCustomVariable::envVariableExists((int) $server['id'], $envVariable)) { + return ApiResponse::error('An environment variable with this name already exists', 'ENV_VARIABLE_EXISTS', 409); + } + + $variableId = ServerCustomVariable::createCustomVariable([ + 'server_id' => (int) $server['id'], + 'user_id' => (int) $user['id'], + 'name' => $name, + 'env_variable' => $envVariable, + 'variable_value' => $value, + 'is_encrypted' => $isEncrypted ? 1 : 0, + ]); + + if (!$variableId) { + return ApiResponse::error('Failed to create custom variable', 'CUSTOM_VARIABLE_CREATE_FAILED', 500); + } + + $syncError = $this->syncServerConfiguration($server); + if ($syncError !== null) { + return $syncError; + } + + $node = Node::getNodeById($server['node_id']); + if ($node) { + $this->logActivity($server, $node, 'server_custom_variable_created', [ + 'server_uuid' => $server['uuid'], + 'env_variable' => $envVariable, + ], $user); + } + + return ApiResponse::success([ + 'custom_variable' => ServerCustomVariable::getCustomVariableById((int) $variableId), + ], 'Custom variable created successfully', 201); + } + + public function deleteCustomVariable(Request $request, string $uuidShort, int $variableId): Response + { + $user = $request->attributes->get('user'); + if (!$user) { + return ApiResponse::error('User not authenticated', 'UNAUTHORIZED', 401); + } + + $server = Server::getServerByUuidShort($uuidShort); + if (!$server) { + return ApiResponse::error('Server not found', 'NOT_FOUND', 404); + } + + $isOwner = (int) $server['owner_id'] === (int) $user['id']; + if (!$isOwner) { + $permissionCheck = $this->checkPermission($request, $server, SubuserPermissions::STARTUP_UPDATE); + if ($permissionCheck !== null) { + return $permissionCheck; + } + } + + $customVariable = ServerCustomVariable::getCustomVariableById($variableId); + if (!$customVariable || (int) $customVariable['server_id'] !== (int) $server['id']) { + return ApiResponse::error('Custom variable not found', 'CUSTOM_VARIABLE_NOT_FOUND', 404); + } + + if (!ServerCustomVariable::deleteCustomVariableForServer($variableId, (int) $server['id'])) { + return ApiResponse::error('Failed to delete custom variable', 'CUSTOM_VARIABLE_DELETE_FAILED', 500); + } + + $syncError = $this->syncServerConfiguration($server); + if ($syncError !== null) { + return $syncError; + } + + $node = Node::getNodeById($server['node_id']); + if ($node) { + $this->logActivity($server, $node, 'server_custom_variable_deleted', [ + 'server_uuid' => $server['uuid'], + 'env_variable' => $customVariable['env_variable'], + ], $user); + } + + return ApiResponse::success([], 'Custom variable deleted successfully', 200); + } + #[OA\Post( path: '/api/user/servers/{uuidShort}/reinstall', summary: 'Reinstall server', @@ -1963,7 +2095,7 @@ private function validateVariableValue(string $value, string $rules, string $fie $parts = explode('|', $rules); $required = in_array('required', $parts, true); $nullable = in_array('nullable', $parts, true); - $isNumeric = in_array('numeric', $parts, true) || in_array('integer', $parts, true); + $isNumeric = in_array('numeric', $parts, true) || in_array('integer', $parts, true) || in_array('int', $parts, true); // string rule is informational for our basic validator if ($value === '') { @@ -2231,6 +2363,34 @@ private function wipeServerFiles(\App\Services\Wings\Wings $wings, string $serve } } + private function syncServerConfiguration(array $server): ?Response + { + $node = Node::getNodeById((int) $server['node_id']); + if (!$node) { + return ApiResponse::error('Node not found', 'NODE_NOT_FOUND', 404); + } + + try { + $wings = new \App\Services\Wings\Wings( + $node['fqdn'], + $node['daemonListen'], + $node['scheme'], + $node['daemon_token'], + 30 + ); + $response = $wings->getServer()->syncServer($server['uuid']); + if (!$response->isSuccessful()) { + return ApiResponse::error('Failed to sync server configuration: ' . $response->getError(), 'WINGS_ERROR', $response->getStatusCode()); + } + } catch (\Exception $e) { + App::getInstance(true)->getLogger()->error('Failed to sync server configuration: ' . $e->getMessage()); + + return ApiResponse::error('Failed to sync server configuration: ' . $e->getMessage(), 'SERVER_SYNC_FAILED', 500); + } + + return null; + } + /** * Helper method to log server activity. */ diff --git a/backend/app/Controllers/User/User/SessionController.php b/backend/app/Controllers/User/User/SessionController.php index d987defa3..ccd2da9af 100755 --- a/backend/app/Controllers/User/User/SessionController.php +++ b/backend/app/Controllers/User/User/SessionController.php @@ -20,18 +20,27 @@ use App\App; use App\Chat\Role; use App\Chat\User; +use App\Chat\Ticket; use App\Chat\Activity; use App\Chat\MailList; use App\Chat\ApiClient; use App\Chat\Permission; +use App\Chat\TicketStatus; +use App\Chat\TicketMessage; +use App\Helpers\TimeHelper; +use App\Chat\TicketCategory; +use App\Chat\TicketPriority; +use App\Chat\UserDataExport; use App\Chat\UserPreference; use App\Helpers\ApiResponse; use OpenApi\Attributes as OA; +use App\Helpers\CaptchaHelper; use App\Config\ConfigInterface; use App\Middleware\AuthMiddleware; use App\CloudFlare\CloudFlareRealIP; use App\Helpers\EmailDomainValidator; use App\Plugins\Events\Events\UserEvent; +use App\Plugins\Events\Events\TicketEvent; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -125,7 +134,7 @@ public function put(Request $request): Response if (!isset($data['turnstile_token']) || trim($data['turnstile_token']) === '') { return ApiResponse::error('Captcha token is required', 'CAPTCHA_TOKEN_REQUIRED'); } - if (!\App\Helpers\CaptchaHelper::validate($data['turnstile_token'], CloudFlareRealIP::getRealIP())) { + if (!CaptchaHelper::validate($data['turnstile_token'], CloudFlareRealIP::getRealIP())) { return ApiResponse::error('Captcha validation failed', 'CAPTCHA_VALIDATION_FAILED'); } // Remove turnstile_token from data after validation (it's not a user field) @@ -346,6 +355,187 @@ public function get(Request $request): Response ], 'Session retrieved', 200); } + #[OA\Post( + path: '/api/user/data-request', + summary: 'Request account data export', + description: 'Create a support ticket requesting a personal data export for the authenticated user.', + tags: ['User - Session'], + responses: [ + new OA\Response(response: 201, description: 'Data request ticket created successfully'), + new OA\Response(response: 400, description: 'Bad request - Missing ticket configuration'), + new OA\Response(response: 401, description: 'Unauthorized - User not authenticated'), + new OA\Response(response: 403, description: 'Forbidden - Ticket system disabled or open ticket limit reached'), + new OA\Response(response: 500, description: 'Internal server error - Failed to create request'), + ] + )] + public function requestDataExport(Request $request): Response + { + $app = App::getInstance(true); + $config = $app->getConfig(); + $data = json_decode($request->getContent(), true); + if (!is_array($data)) { + $data = []; + } + + if ($config->getSetting(ConfigInterface::TICKET_SYSTEM_ENABLED, 'true') !== 'true') { + return ApiResponse::error('Ticket system is disabled', 'TICKET_SYSTEM_DISABLED', 403); + } + + if ($config->getSetting(ConfigInterface::TURNSTILE_ENABLED, 'false') === 'true') { + if (!isset($data['turnstile_token']) || trim((string) $data['turnstile_token']) === '') { + return ApiResponse::error('Captcha token is required', 'CAPTCHA_TOKEN_REQUIRED', 400); + } + if (!CaptchaHelper::validate((string) $data['turnstile_token'], CloudFlareRealIP::getRealIP())) { + return ApiResponse::error('Captcha validation failed', 'CAPTCHA_VALIDATION_FAILED', 400); + } + } + + $user = AuthMiddleware::getCurrentUser($request); + if ($user == null) { + return ApiResponse::error('You are not allowed to access this resource!', 'INVALID_ACCOUNT_TOKEN', 400, []); + } + + if (UserDataExport::hasRecentRequestForUser((string) $user['uuid'], 24)) { + return ApiResponse::error( + 'You can request your personal data once every 24 hours. Please try again later.', + 'DATA_EXPORT_RATE_LIMITED', + 429 + ); + } + + $maxOpenTickets = (int) $config->getSetting(ConfigInterface::TICKET_SYSTEM_MAX_OPEN_TICKETS, '10'); + if ($maxOpenTickets > 0 && Ticket::getOpenTicketsCount($user['uuid']) >= $maxOpenTickets) { + return ApiResponse::error( + "You have reached the maximum number of open tickets ({$maxOpenTickets}). Please close or wait for resolution of existing tickets before creating a new one.", + 'MAX_OPEN_TICKETS_REACHED', + 403 + ); + } + + $categories = TicketCategory::getAll('privacy', 1, 0); + if (empty($categories)) { + $categories = TicketCategory::getAll('data', 1, 0); + } + if (empty($categories)) { + $categories = TicketCategory::getAll(null, 1, 0); + } + if (empty($categories)) { + return ApiResponse::error('No ticket categories configured', 'NO_CATEGORIES', 500); + } + + $priorities = TicketPriority::getAll('normal', 1, 0); + if (empty($priorities)) { + $priorities = TicketPriority::getAll('medium', 1, 0); + } + if (empty($priorities)) { + $priorities = TicketPriority::getAll('low', 1, 0); + } + if (empty($priorities)) { + $priorities = TicketPriority::getAll(null, 1, 0); + } + if (empty($priorities)) { + return ApiResponse::error('No ticket priorities configured', 'NO_PRIORITIES', 500); + } + + $statuses = TicketStatus::getAll(null, 100, 0); + $openStatus = null; + foreach ($statuses as $status) { + if (strtolower($status['name']) === 'open') { + $openStatus = $status; + break; + } + } + if (!$openStatus && !empty($statuses)) { + $openStatus = $statuses[0]; + } + if (!$openStatus) { + return ApiResponse::error('No ticket statuses configured', 'NO_STATUSES', 500); + } + + $title = 'Personal data request'; + $description = implode("\n", [ + 'I am requesting a copy of my personal data under UK GDPR.', + '', + 'Account details:', + '- User UUID: ' . $user['uuid'], + '- Username: ' . $user['username'], + '- Email: ' . $user['email'], + '', + 'Please provide the data export or advise on any identity verification and fulfilment timeline.', + ]); + + $ticketData = [ + 'uuid' => Ticket::generateUuid(), + 'user_uuid' => $user['uuid'], + 'server_id' => null, + 'category_id' => (int) $categories[0]['id'], + 'priority_id' => (int) $priorities[0]['id'], + 'status_id' => (int) $openStatus['id'], + 'title' => $title, + 'description' => $description, + ]; + + $ticketId = Ticket::create($ticketData); + if (!$ticketId) { + return ApiResponse::error('Failed to create data request', 'CREATE_FAILED', 500); + } + + $messageId = TicketMessage::create([ + 'ticket_id' => $ticketId, + 'user_uuid' => $user['uuid'], + 'message' => $description, + 'is_internal' => false, + ]); + + if (!$messageId) { + $app->getLogger()->warning('Failed to create initial message for data request ticket: ' . $ticketId); + } + + $exportUuid = UserDataExport::generateUuid(); + $exportId = UserDataExport::create([ + 'uuid' => $exportUuid, + 'user_uuid' => $user['uuid'], + 'ticket_id' => $ticketId, + ]); + + if (!$exportId) { + $app->getLogger()->error('Failed to queue data export for ticket: ' . $ticketId); + + return ApiResponse::error('Data request ticket was created, but the export could not be queued. Please contact support.', 'EXPORT_QUEUE_FAILED', 500); + } + + $ticket = Ticket::getById($ticketId); + + Activity::createActivity([ + 'user_uuid' => $user['uuid'], + 'name' => 'request_data_export', + 'context' => 'Requested personal data export', + 'ip_address' => CloudFlareRealIP::getRealIP(), + ]); + + global $eventManager; + if (isset($eventManager) && $eventManager !== null) { + $eventManager->emit( + TicketEvent::onTicketCreated(), + [ + 'ticket' => $ticket, + 'ticket_id' => $ticketId, + 'user_uuid' => $user['uuid'], + ] + ); + } + + return ApiResponse::success([ + 'ticket' => $ticket, + 'message_id' => $messageId, + 'export' => [ + 'id' => $exportId, + 'uuid' => $exportUuid, + 'status' => 'pending', + ], + ], 'Data request created successfully', 201); + } + #[OA\Post( path: '/api/user/avatar', summary: 'Upload user avatar', @@ -605,6 +795,15 @@ public function updatePreferences(Request $request): Response $data['favorite_server_uuids'] = $cleanFavorites; } + if (array_key_exists('timezone', $data)) { + if ($data['timezone'] === null || $data['timezone'] === '') { + // Treat empty value as "clear preference, fall back to browser/global default" + $data['timezone'] = null; + } elseif (!is_string($data['timezone']) || !TimeHelper::isValidTimezone($data['timezone'])) { + return ApiResponse::error('Invalid timezone identifier', 'INVALID_TIMEZONE', 400, []); + } + } + // Update preferences (merges with existing) $success = UserPreference::updatePreferences($user['uuid'], $data); @@ -715,7 +914,7 @@ public function getMails(Request $request): Response 'subject' => $mail['subject'] ?? '', 'body' => $mail['body'] ?? '', 'status' => $mail['status'] ?? 'pending', - 'created_at' => $mail['created_at'] ?? '', + 'created_at' => TimeHelper::toIso8601($mail['created_at'] ?? null), ]; $mails[] = $mailData; } @@ -849,8 +1048,8 @@ public function getActivities(Request $request): Response 'name' => $activity['name'] ?? '', 'context' => $activity['context'] ?? null, 'ip_address' => $ipAddress, - 'created_at' => $activity['created_at'] ?? '', - 'updated_at' => $activity['updated_at'] ?? '', + 'created_at' => TimeHelper::toIso8601($activity['created_at'] ?? null), + 'updated_at' => TimeHelper::toIso8601($activity['updated_at'] ?? null), ]; $formattedActivities[] = $activityData; } diff --git a/backend/app/Controllers/User/Vds/VmUserActivityController.php b/backend/app/Controllers/User/Vds/VmUserActivityController.php index d355f1db5..ef19eca27 100644 --- a/backend/app/Controllers/User/Vds/VmUserActivityController.php +++ b/backend/app/Controllers/User/Vds/VmUserActivityController.php @@ -17,6 +17,7 @@ namespace App\Controllers\User\Vds; +use App\Helpers\TimeHelper; use App\Helpers\ApiResponse; use OpenApi\Attributes as OA; use App\Chat\VmInstanceActivity; @@ -105,8 +106,12 @@ public function getVmInstanceActivities(Request $request, int $id): Response vmInstanceId: (int) $vmInstance['id'], ); + $activities = is_array($result['data'] ?? null) + ? TimeHelper::normaliseRows($result['data'], ['timestamp']) + : []; + return ApiResponse::success([ - 'activities' => $result['data'], + 'activities' => $activities, 'pagination' => $result['pagination'], ], 'Activities fetched', 200); } diff --git a/backend/app/Controllers/User/VdsChatbotController.php b/backend/app/Controllers/User/VdsChatbotController.php index 6825a0b00..33dc5808e 100644 --- a/backend/app/Controllers/User/VdsChatbotController.php +++ b/backend/app/Controllers/User/VdsChatbotController.php @@ -22,10 +22,12 @@ use App\Helpers\ApiResponse; use OpenApi\Attributes as OA; use App\Chat\ChatConversation; +use App\Services\Chatbot\TokenUsage; use App\Plugins\Events\Events\ChatbotEvent; use App\Services\Chatbot\VdsChatbotService; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; #[OA\Schema( schema: 'VdsChatbotRequest', @@ -70,105 +72,28 @@ class VdsChatbotController )] public function chat(Request $request): Response { - $currentUser = $request->attributes->get('user'); - - if (!$currentUser || !isset($currentUser['id'])) { - return ApiResponse::error('User not authenticated', 'UNAUTHORIZED', 401); - } - - // Check if chatbot is enabled - $app = App::getInstance(true); - $config = $app->getConfig(); - $enabled = $config->getSetting(\App\Config\ConfigInterface::CHATBOT_ENABLED, 'true'); - if ($enabled !== 'true') { - return ApiResponse::error('The AI chatbot is currently disabled by the administrator.', 'CHATBOT_DISABLED', 403); - } - - $data = json_decode($request->getContent(), true); - - if (!isset($data['message']) || empty(trim($data['message']))) { - return ApiResponse::error('Message is required', 'INVALID_REQUEST', 400); - } - - $message = trim($data['message']); - $history = $data['history'] ?? []; - $pageContext = $data['pageContext'] ?? []; - $conversationId = $data['conversation_id'] ?? null; - try { - // Get or create conversation - $conversation = null; - if ($conversationId) { - $conversation = ChatConversation::getConversationById((int) $conversationId); - // Verify conversation belongs to user - if ($conversation && $conversation['user_uuid'] !== $currentUser['uuid']) { - return ApiResponse::error('Conversation not found', 'NOT_FOUND', 404); - } - } - - // Create new conversation if needed - if (!$conversation) { - $conversationId = ChatConversation::createConversation([ - 'user_uuid' => $currentUser['uuid'], - 'title' => substr($message, 0, 255), - ]); - if (!$conversationId) { - return ApiResponse::error('Failed to create conversation', 'SERVER_ERROR', 500); - } - $conversation = ChatConversation::getConversationById($conversationId); - } - - // Load conversation history from database if not provided - if (empty($history) && $conversation) { - $dbMessages = ChatMessage::getMessagesByConversation($conversation['id'], 50); - $history = array_map(function ($msg) { - return [ - 'role' => $msg['role'], - 'content' => $msg['content'], - ]; - }, $dbMessages); - } - - // Save user message to database - ChatMessage::createMessage([ - 'conversation_id' => $conversation['id'], - 'role' => 'user', - 'content' => $message, - ]); - - // Get conversation memory - $conversationMemory = $conversation['memory'] ?? ''; - $pageContext['conversation_memory'] = $conversationMemory; - - // Process message through VDS AI - $chatbotService = new VdsChatbotService(); - $result = $chatbotService->processMessage($message, $history, $currentUser, $pageContext); - - // Update message count - $messageCount = ChatMessage::getMessageCount($conversation['id']); - ChatConversation::updateConversation($conversation['id'], [ - 'message_count' => $messageCount, - ]); - - // Save AI response to database - ChatMessage::createMessage([ - 'conversation_id' => $conversation['id'], - 'role' => 'assistant', - 'content' => $result['response'], - 'model' => $result['model'] ?? null, - ]); - - // Update conversation timestamp - ChatConversation::updateConversation($conversation['id'], [ - 'updated_at' => date('Y-m-d H:i:s'), - ]); + $payload = $this->processChatRequest($request); return ApiResponse::success([ - 'response' => $result['response'], - 'model' => $result['model'] ?? 'FeatherPanel VDS AI', - 'conversation_id' => $conversation['id'], - 'tool_executions' => $result['tool_executions'] ?? [], + 'response' => $payload['response'], + 'model' => $payload['model'], + 'conversation_id' => $payload['conversation_id'], + 'message_id' => $payload['assistant_message_id'], + 'user_message_id' => $payload['user_message_id'], + 'usage' => $payload['usage'], + 'user_usage' => $payload['user_usage'], + 'tool_executions' => $payload['tool_executions'], + 'tool_activity' => $payload['tool_activity'], ], 'Message processed successfully'); + } catch (\InvalidArgumentException $e) { + return ApiResponse::error($e->getMessage(), 'INVALID_REQUEST', 400); + } catch (\RuntimeException $e) { + $message = $e->getMessage(); + $code = $message === 'User not authenticated' ? 401 : ($message === 'Conversation not found' ? 404 : (str_contains($message, 'disabled') ? 403 : 500)); + $errorCode = $code === 401 ? 'UNAUTHORIZED' : ($code === 404 ? 'NOT_FOUND' : ($code === 403 ? 'CHATBOT_DISABLED' : 'CHATBOT_ERROR')); + + return ApiResponse::error($message, $errorCode, $code); } catch (\Exception $e) { App::getInstance(true)->getLogger()->error('VDS Chatbot error: ' . $e->getMessage()); @@ -180,6 +105,32 @@ public function chat(Request $request): Response } } + public function streamChat(Request $request): Response + { + return new StreamedResponse(function () use ($request): void { + $emit = function (string $type, array $payload = []): void { + echo "event: {$type}\n"; + echo 'data: ' . json_encode($payload, JSON_UNESCAPED_SLASHES) . "\n\n"; + @ob_flush(); + flush(); + }; + + try { + $payload = $this->processChatRequest($request, $emit); + $emit('final', $payload); + } catch (\Exception $e) { + App::getInstance(true)->getLogger()->error('VDS Chatbot stream error: ' . $e->getMessage()); + $emit('error', [ + 'message' => 'Failed to process message. Please try again.', + ]); + } + }, 200, [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'X-Accel-Buffering' => 'no', + ]); + } + #[OA\Get( path: '/api/user/vds-chatbot/conversations', summary: 'Get user VDS chatbot conversations', @@ -414,6 +365,192 @@ public function updateMemory(Request $request, int $id): Response } } + private function processChatRequest(Request $request, ?callable $emit = null): array + { + $currentUser = $request->attributes->get('user'); + + if (!$currentUser || !isset($currentUser['id'])) { + throw new \RuntimeException('User not authenticated'); + } + + $app = App::getInstance(true); + $config = $app->getConfig(); + $enabled = $config->getSetting(\App\Config\ConfigInterface::CHATBOT_ENABLED, 'true'); + if ($enabled !== 'true') { + throw new \RuntimeException('The AI chatbot is currently disabled by the administrator.'); + } + + $data = json_decode($request->getContent(), true) ?: []; + if (!isset($data['message']) || empty(trim($data['message']))) { + throw new \InvalidArgumentException('Message is required'); + } + + $message = trim($data['message']); + $history = $data['history'] ?? []; + $pageContext = $data['pageContext'] ?? []; + $conversationId = $data['conversation_id'] ?? null; + + $conversation = null; + if ($conversationId) { + $conversation = ChatConversation::getConversationById((int) $conversationId); + if ($conversation && $conversation['user_uuid'] !== $currentUser['uuid']) { + throw new \RuntimeException('Conversation not found'); + } + } + + if (!$conversation) { + $conversationId = ChatConversation::createConversation([ + 'user_uuid' => $currentUser['uuid'], + 'title' => substr($message, 0, 255), + ]); + if (!$conversationId) { + throw new \RuntimeException('Failed to create conversation'); + } + $conversation = ChatConversation::getConversationById($conversationId); + } + + $dbMessages = ChatMessage::getMessagesByConversation($conversation['id'], 50); + if (empty($history) && $conversation) { + $history = $this->buildCompactHistory($dbMessages, $conversation); + } else { + $history = array_slice($history, -6); + } + + $userUsage = TokenUsage::estimate($message); + $userMessageId = ChatMessage::createMessage([ + 'conversation_id' => $conversation['id'], + 'role' => 'user', + 'content' => $message, + 'input_tokens' => $userUsage['input_tokens'], + 'total_tokens' => $userUsage['total_tokens'], + 'token_source' => $userUsage['source'], + ]); + + if ($emit !== null) { + $emit('conversation', [ + 'conversation_id' => $conversation['id'], + 'user_message_id' => $userMessageId, + 'user_usage' => $userUsage, + ]); + } + + $summary = $this->refreshContextSummary($conversation, $dbMessages, $message); + if ($emit !== null && $summary !== '') { + $emit('status', ['message' => 'Compacting conversation context']); + } + $pageContext['conversation_memory'] = trim(($conversation['memory'] ?? '') . "\n\n" . $summary); + + $chatbotService = new VdsChatbotService(); + $result = $chatbotService->processMessage($message, $history, $currentUser, $pageContext, $emit); + $usage = $result['usage'] ?? TokenUsage::estimate($message, $result['response'] ?? ''); + $toolActivity = $result['tool_activity'] ?? []; + + $assistantMessageId = ChatMessage::createMessage([ + 'conversation_id' => $conversation['id'], + 'role' => 'assistant', + 'content' => $result['response'], + 'model' => $result['model'] ?? null, + 'input_tokens' => $usage['input_tokens'] ?? null, + 'output_tokens' => $usage['output_tokens'] ?? null, + 'total_tokens' => $usage['total_tokens'] ?? null, + 'token_source' => $usage['source'] ?? null, + 'tool_activity' => $toolActivity, + 'usage_json' => $usage, + ]); + + $messageCount = ChatMessage::getMessageCount($conversation['id']); + ChatConversation::updateConversation($conversation['id'], [ + 'message_count' => $messageCount, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + return [ + 'response' => $result['response'], + 'model' => $result['model'] ?? 'FeatherPanel VDS AI', + 'conversation_id' => $conversation['id'], + 'user_message_id' => $userMessageId, + 'assistant_message_id' => $assistantMessageId, + 'usage' => $usage, + 'user_usage' => $userUsage, + 'tool_executions' => $result['tool_executions'] ?? [], + 'tool_activity' => $toolActivity, + ]; + } + + private function buildCompactHistory(array $dbMessages, array $conversation): array + { + $summary = $conversation['context_summary'] ?? ''; + $recentMessages = array_slice($dbMessages, -6); + $history = []; + + if ($summary !== '') { + $history[] = [ + 'role' => 'assistant', + 'content' => "Conversation context summary:\n{$summary}", + ]; + } + + foreach ($recentMessages as $msg) { + $content = $msg['content']; + if (!empty($msg['tool_activity']) && is_array($msg['tool_activity'])) { + $content .= "\n\n[Tool/activity results from this assistant turn]\n" . $this->formatToolActivityForHistory($msg['tool_activity']); + } + $history[] = [ + 'role' => $msg['role'], + 'content' => $content, + ]; + } + + return $history; + } + + private function refreshContextSummary(array $conversation, array $dbMessages, string $latestMessage): string + { + $existing = trim((string) ($conversation['context_summary'] ?? '')); + if (count($dbMessages) < 10 || !ChatConversation::hasColumn('context_summary')) { + return $existing; + } + + $olderMessages = array_slice($dbMessages, 0, -6); + $lines = []; + foreach ($olderMessages as $message) { + $content = trim(preg_replace('/\s+/', ' ', (string) $message['content'])); + if (!empty($message['tool_activity']) && is_array($message['tool_activity'])) { + $content .= ' Tool/activity: ' . preg_replace('/\s+/', ' ', $this->formatToolActivityForHistory($message['tool_activity'])); + } + if ($content === '') { + continue; + } + $lines[] = strtoupper((string) $message['role']) . ': ' . mb_substr($content, 0, 220); + } + + $summary = trim($existing . "\n" . implode("\n", array_slice($lines, -12))); + $summary = mb_substr($summary, -3500); + $summary .= "\nLatest user message: " . mb_substr($latestMessage, 0, 300); + + ChatConversation::updateConversation((int) $conversation['id'], [ + 'context_summary' => $summary, + 'context_summary_updated_at' => date('Y-m-d H:i:s'), + ]); + + return $summary; + } + + private function formatToolActivityForHistory(array $toolActivity): string + { + $lines = []; + foreach ($toolActivity as $activity) { + if (!is_array($activity)) { + continue; + } + $status = ($activity['success'] ?? null) === false ? 'failed' : (($activity['success'] ?? null) ? 'completed' : 'planned'); + $summary = isset($activity['summary']) ? ' - ' . $activity['summary'] : ''; + $lines[] = ($activity['tool'] ?? 'unknown_tool') . ": {$status}{$summary}"; + } + + return implode("\n", $lines); + } + private static function emitEvent(string $eventName, array $payload): void { global $eventManager; diff --git a/backend/app/Controllers/Wings/Server/WingsServerInfoController.php b/backend/app/Controllers/Wings/Server/WingsServerInfoController.php index 2dd8cbcf2..9adcb674f 100755 --- a/backend/app/Controllers/Wings/Server/WingsServerInfoController.php +++ b/backend/app/Controllers/Wings/Server/WingsServerInfoController.php @@ -26,6 +26,8 @@ use App\Chat\ServerVariable; use App\Helpers\ApiResponse; use OpenApi\Attributes as OA; +use App\Chat\ServerCustomVariable; +use App\Helpers\WingsFileTrashConfig; use App\Plugins\Events\Events\WingsEvent; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -181,6 +183,10 @@ public function getServer(Request $request, string $uuid): Response $environment[$variable['env_variable']] = $variable['variable_value']; } + foreach (ServerCustomVariable::getEnvironmentVariablesByServerId((int) $server['id']) as $envVariable => $value) { + $environment[$envVariable] = $value; + } + // Add default environment variables based on database fields $environment['P_SERVER_LOCATION'] = $node['location_id'] ?? ''; $environment['P_SERVER_UUID'] = $server['uuid']; @@ -476,6 +482,7 @@ public function getServer(Request $request, string $uuid): Response 'oom_disabled' => (bool) $server['oom_disabled'], 'requires_rebuild' => false, ], + 'file_trash' => WingsFileTrashConfig::forWings(), ]; $wingsMounts = Mount::getWingsMountsForServer((int) $server['id']); if ($wingsMounts !== []) { diff --git a/backend/app/Controllers/Wings/Server/WingsServerListController.php b/backend/app/Controllers/Wings/Server/WingsServerListController.php index 77b59dfe3..21166cdb1 100755 --- a/backend/app/Controllers/Wings/Server/WingsServerListController.php +++ b/backend/app/Controllers/Wings/Server/WingsServerListController.php @@ -26,6 +26,8 @@ use App\Chat\ServerVariable; use App\Helpers\ApiResponse; use OpenApi\Attributes as OA; +use App\Chat\ServerCustomVariable; +use App\Helpers\WingsFileTrashConfig; use App\Plugins\Events\Events\WingsEvent; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -146,6 +148,10 @@ public function getRemoteServers(Request $request): Response $environment[$variable['env_variable']] = $variable['variable_value']; } + foreach (ServerCustomVariable::getEnvironmentVariablesByServerId((int) $server['id']) as $envVariable => $value) { + $environment[$envVariable] = $value; + } + // Add default environment variables $environment['P_SERVER_LOCATION'] = $node['location_id'] ?? ''; $environment['P_SERVER_UUID'] = $server['uuid']; @@ -371,6 +377,7 @@ public function getRemoteServers(Request $request): Response 'oom_disabled' => (bool) $server['oom_disabled'], 'requires_rebuild' => false, ], + 'file_trash' => WingsFileTrashConfig::forWings(), ]; $listMounts = Mount::getWingsMountsForServer((int) $server['id']); if ($listMounts !== []) { diff --git a/backend/app/Helpers/ModerationReasonHelper.php b/backend/app/Helpers/ModerationReasonHelper.php new file mode 100644 index 000000000..e231dac71 --- /dev/null +++ b/backend/app/Helpers/ModerationReasonHelper.php @@ -0,0 +1,205 @@ +. + */ + +namespace App\Helpers; + +use App\Chat\User; + +class ModerationReasonHelper +{ + public const CATEGORY_LABELS = [ + 'payment_overdue' => 'Payment overdue', + 'terms_violation' => 'Terms of service violation', + 'abuse_harassment' => 'Abuse or harassment', + 'resource_abuse' => 'Resource abuse', + 'security_threat' => 'Security threat', + 'spam' => 'Spam or advertising', + 'chargeback' => 'Chargeback or fraud', + 'manual_review' => 'Manual review required', + 'featherzerotrust' => 'FeatherZeroTrust detection', + 'other' => 'Other', + ]; + + public static function normalizeDetails(string $details): string + { + return trim(preg_replace('/\s+/u', ' ', $details) ?? ''); + } + + public static function formatReason(?string $category, string $details): string + { + $details = self::normalizeDetails($details); + $category = $category !== null ? trim($category) : ''; + + if ($category !== '' && isset(self::CATEGORY_LABELS[$category])) { + $label = self::CATEGORY_LABELS[$category]; + if ($details === '') { + return $label; + } + + return $label . ': ' . $details; + } + + return $details; + } + + public static function validateReason(string $reason): ?string + { + $reason = self::normalizeDetails($reason); + if ($reason === '') { + return 'A reason is required when suspending or banning.'; + } + if (mb_strlen($reason) < 3) { + return 'Reason must be at least 3 characters.'; + } + if (mb_strlen($reason) > 2000) { + return 'Reason must be 2000 characters or fewer.'; + } + + return null; + } + + /** + * @return array{reason:string,category:?string,details:string}|null + */ + public static function parseRequestBody(array $body): ?array + { + $category = isset($body['reason_category']) && is_string($body['reason_category']) + ? trim($body['reason_category']) + : null; + if ($category === '') { + $category = null; + } + + $details = ''; + if (isset($body['reason_details']) && is_string($body['reason_details'])) { + $details = $body['reason_details']; + } elseif (isset($body['reason']) && is_string($body['reason'])) { + $details = $body['reason']; + } + + $reason = self::formatReason($category, $details); + if (self::validateReason($reason) !== null && isset($body['reason']) && is_string($body['reason'])) { + $reason = self::normalizeDetails($body['reason']); + } + + return [ + 'reason' => $reason, + 'category' => $category, + 'details' => self::normalizeDetails($details), + ]; + } + + /** + * @param array $row + * + * @return array + */ + public static function enrichUserBanMetadata(array $row): array + { + $staffUuid = isset($row['banned_by_uuid']) ? trim((string) $row['banned_by_uuid']) : ''; + if ($staffUuid !== '') { + $staff = User::getUserByUuid($staffUuid); + $row['banned_by'] = $staff ? [ + 'uuid' => $staff['uuid'], + 'username' => $staff['username'], + ] : [ + 'uuid' => $staffUuid, + 'username' => null, + ]; + } else { + $row['banned_by'] = null; + } + + return $row; + } + + /** + * @param array $row + * + * @return array + */ + public static function enrichServerSuspensionMetadata(array $row): array + { + $staffUuid = isset($row['suspended_by_uuid']) ? trim((string) $row['suspended_by_uuid']) : ''; + if ($staffUuid !== '') { + $staff = User::getUserByUuid($staffUuid); + $row['suspended_by'] = $staff ? [ + 'uuid' => $staff['uuid'], + 'username' => $staff['username'], + ] : [ + 'uuid' => $staffUuid, + 'username' => $staffUuid === 'system' ? 'System' : null, + ]; + } else { + $row['suspended_by'] = null; + } + + return $row; + } + + /** + * @return array + */ + public static function banAppliedFields(string $reason, ?array $staffUser): array + { + return [ + 'banned' => 'true', + 'ban_reason' => $reason, + 'banned_at' => gmdate('Y-m-d H:i:s'), + 'banned_by_uuid' => is_array($staffUser) ? ($staffUser['uuid'] ?? null) : null, + ]; + } + + /** + * @return array + */ + public static function banClearedFields(): array + { + return [ + 'banned' => 'false', + 'ban_reason' => null, + 'banned_at' => null, + 'banned_by_uuid' => null, + ]; + } + + /** + * @return array + */ + public static function suspensionAppliedFields(string $reason, ?array $staffUser): array + { + return [ + 'suspended' => 1, + 'suspension_reason' => $reason, + 'suspended_at' => gmdate('Y-m-d H:i:s'), + 'suspended_by_uuid' => is_array($staffUser) ? ($staffUser['uuid'] ?? null) : null, + ]; + } + + /** + * @return array + */ + public static function suspensionClearedFields(): array + { + return [ + 'suspended' => 0, + 'suspension_reason' => null, + 'suspended_at' => null, + 'suspended_by_uuid' => null, + ]; + } +} diff --git a/backend/app/Helpers/TimeHelper.php b/backend/app/Helpers/TimeHelper.php new file mode 100644 index 000000000..06d611789 --- /dev/null +++ b/backend/app/Helpers/TimeHelper.php @@ -0,0 +1,132 @@ +. + */ + +namespace App\Helpers; + +/** + * Helpers for normalising datetime values that are exposed through the API. + * + * All datetime columns in the database are stored as UTC (the PDO connection + * forces `SET time_zone = '+00:00'`). API responses must therefore tag every + * datetime value with an explicit UTC offset (`Z`) so the frontend can render + * it in the user's preferred timezone unambiguously. + * + * Without this normalisation, MySQL returns naive datetime strings like + * `2026-05-17 19:00:00` which `new Date(...)` in JavaScript interprets as + * **local browser time**, causing a wall-clock offset bug equal to the user's + * timezone offset (the source of the historical "times are 2 hours behind" + * reports for Europe/Paris users). + */ +class TimeHelper +{ + /** + * Convert a MySQL-formatted datetime string (assumed UTC) to ISO-8601 with + * an explicit `Z` suffix so JavaScript parses it as UTC. + * + * Accepted inputs include: + * - `Y-m-d H:i:s` (e.g. `2026-05-17 19:00:00`) + * - `Y-m-d\TH:i:s` (e.g. `2026-05-17T19:00:00`) + * - Anything else `DateTime` can parse (treated as UTC if no offset). + * + * Returns `null` for null/empty input and the original value (unchanged) + * if it cannot be parsed, so misformatted historical data does not blow + * up the request. + */ + public static function toIso8601(mixed $value): ?string + { + if ($value === null) { + return null; + } + + if (!is_string($value)) { + return null; + } + + $trimmed = trim($value); + if ($trimmed === '' || $trimmed === '0000-00-00 00:00:00') { + return null; + } + + try { + $dt = new \DateTimeImmutable($trimmed, new \DateTimeZone('UTC')); + } catch (\Exception $e) { + return $trimmed; + } + + // setTimezone(UTC) is a no-op when input was naive (DateTimeImmutable + // honours the constructor TZ), but normalises any value that already + // carried an explicit offset. + return $dt->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'); + } + + /** + * Normalise the standard `created_at` / `updated_at` (and any caller-supplied + * additional keys) on an associative row in place, returning the new array. + * + * @param array $row + * @param string[] $extraKeys additional datetime keys to normalise + * + * @return array + */ + public static function normaliseRow(array $row, array $extraKeys = []): array + { + $keys = array_unique(array_merge(['created_at', 'updated_at'], $extraKeys)); + foreach ($keys as $key) { + if (array_key_exists($key, $row)) { + $row[$key] = self::toIso8601($row[$key]); + } + } + + return $row; + } + + /** + * Apply `normaliseRow` to a list of rows. + * + * @param array> $rows + * @param string[] $extraKeys + * + * @return array> + */ + public static function normaliseRows(array $rows, array $extraKeys = []): array + { + return array_map(static fn (array $row) => self::normaliseRow($row, $extraKeys), $rows); + } + + /** + * Check whether a string looks like a valid IANA timezone identifier + * (e.g. `Europe/Paris`, `America/Los_Angeles`, `UTC`). + * + * Uses PHP's built-in zone list rather than a custom regex so we accept + * exactly the same set of zones that PHP itself can render with. + */ + public static function isValidTimezone(string $timezone): bool + { + $trimmed = trim($timezone); + if ($trimmed === '') { + return false; + } + + try { + new \DateTimeZone($trimmed); + + return true; + } catch (\Exception $e) { + return false; + } + } +} diff --git a/backend/app/Helpers/WingsFileTrashConfig.php b/backend/app/Helpers/WingsFileTrashConfig.php new file mode 100644 index 000000000..414802543 --- /dev/null +++ b/backend/app/Helpers/WingsFileTrashConfig.php @@ -0,0 +1,43 @@ +. + */ + +namespace App\Helpers; + +use App\App; +use App\Config\ConfigInterface; + +/** + * File trash settings pushed to FeatherWings in server configuration (SFTP, etc.). + */ +class WingsFileTrashConfig +{ + /** + * @return array{enabled: bool, max_size_bytes: int, retention_days: int} + */ + public static function forWings(): array + { + $config = App::getInstance(true)->getConfig(); + $maxMb = (int) $config->getSetting(ConfigInterface::FILE_TRASH_MAX_SIZE_MB, '512'); + $retentionDays = (int) $config->getSetting(ConfigInterface::FILE_TRASH_RETENTION_DAYS, '30'); + + return [ + 'enabled' => $config->getSetting(ConfigInterface::FILE_TRASH_ENABLED, 'false') === 'true', + 'max_size_bytes' => $maxMb > 0 ? $maxMb * 1024 * 1024 : 0, + 'retention_days' => $retentionDays, + ]; + } +} diff --git a/backend/app/KPI/Admin/SystemAnalytics.php b/backend/app/KPI/Admin/SystemAnalytics.php index e603e082b..2c1b6a950 100755 --- a/backend/app/KPI/Admin/SystemAnalytics.php +++ b/backend/app/KPI/Admin/SystemAnalytics.php @@ -18,6 +18,7 @@ namespace App\KPI\Admin; use App\Chat\Database; +use App\Helpers\TimeHelper; /** * System Analytics and KPI service for mail, API keys, SSH keys, plugins, and system features. @@ -90,6 +91,7 @@ public static function getMailQueueStats(): array LIMIT 10 "); $recentQueued = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; + $recentQueued = TimeHelper::normaliseRows($recentQueued); return [ 'total_queued' => $pending, diff --git a/backend/app/Mail/templates/AccountBanned.php b/backend/app/Mail/templates/AccountBanned.php index b556d7e36..cb75b74fc 100755 --- a/backend/app/Mail/templates/AccountBanned.php +++ b/backend/app/Mail/templates/AccountBanned.php @@ -39,6 +39,7 @@ public static function getTemplate(array $data): string 'dashboard_url' => $data['app_url'] . '/dashboard', 'support_url' => $data['app_support_url'], 'suspension_time' => $data['suspension_time'], + 'suspension_reason' => $data['suspension_reason'] ?? '', ]); } @@ -59,6 +60,7 @@ public static function parseTemplate(string $template, array $data): string $template = str_replace('{dashboard_url}', $data['dashboard_url'], $template); $template = str_replace('{support_url}', $data['support_url'], $template); $template = str_replace('{suspension_time}', $data['suspension_time'], $template); + $template = str_replace('{suspension_reason}', $data['suspension_reason'] ?? '', $template); return $template; } @@ -130,6 +132,7 @@ private static function getSubject(array $data): string 'dashboard_url' => $data['app_url'] . '/dashboard', 'support_url' => $data['app_support_url'], 'suspension_time' => $data['suspension_time'], + 'suspension_reason' => $data['suspension_reason'] ?? '', ]); } } diff --git a/backend/app/Mail/templates/ServerBanned.php b/backend/app/Mail/templates/ServerBanned.php index 4d477b559..57ba14cd0 100755 --- a/backend/app/Mail/templates/ServerBanned.php +++ b/backend/app/Mail/templates/ServerBanned.php @@ -39,6 +39,7 @@ public static function getTemplate(array $data): string 'dashboard_url' => $data['app_url'] . '/dashboard', 'support_url' => $data['app_support_url'], 'server_name' => $data['server_name'], + 'suspension_reason' => $data['suspension_reason'] ?? '', ]); } @@ -59,6 +60,7 @@ public static function parseTemplate(string $template, array $data): string $template = str_replace('{dashboard_url}', $data['dashboard_url'], $template); $template = str_replace('{support_url}', $data['support_url'], $template); $template = str_replace('{server_name}', $data['server_name'], $template); + $template = str_replace('{suspension_reason}', $data['suspension_reason'] ?? '', $template); return $template; } @@ -134,6 +136,7 @@ private static function getSubject(array $data): string 'dashboard_url' => $data['app_url'] . '/dashboard', 'support_url' => $data['app_support_url'], 'server_name' => $data['server_name'], + 'suspension_reason' => $data['suspension_reason'] ?? '', ]); } } diff --git a/backend/app/Mail/templates/VmSuspended.php b/backend/app/Mail/templates/VmSuspended.php index fdc763e58..23d255efc 100644 --- a/backend/app/Mail/templates/VmSuspended.php +++ b/backend/app/Mail/templates/VmSuspended.php @@ -36,6 +36,7 @@ public static function getTemplate(array $data): string 'dashboard_url' => $data['app_url'] . '/dashboard', 'support_url' => $data['app_support_url'], 'vm_hostname' => $data['vm_hostname'], + 'suspension_reason' => $data['suspension_reason'] ?? '', ]); } @@ -53,6 +54,7 @@ public static function parseTemplate(string $template, array $data): string $template = str_replace('{dashboard_url}', $data['dashboard_url'], $template); $template = str_replace('{support_url}', $data['support_url'], $template); $template = str_replace('{vm_hostname}', $data['vm_hostname'], $template); + $template = str_replace('{suspension_reason}', $data['suspension_reason'] ?? '', $template); return $template; } @@ -125,6 +127,7 @@ private static function getSubject(array $data): string 'dashboard_url' => $data['app_url'] . '/dashboard', 'support_url' => $data['app_support_url'], 'vm_hostname' => $data['vm_hostname'], + 'suspension_reason' => $data['suspension_reason'] ?? '', ]); } } diff --git a/backend/app/Plugins/Events/Events/SpellsEvent.php b/backend/app/Plugins/Events/Events/SpellsEvent.php index 8a55cf21e..fc3d3eee1 100755 --- a/backend/app/Plugins/Events/Events/SpellsEvent.php +++ b/backend/app/Plugins/Events/Events/SpellsEvent.php @@ -70,6 +70,14 @@ public static function onSpellsByRealmRetrieved(): string return 'featherpanel:admin:spells:by:realm:retrieved'; } + /** + * Callback: int realm id, array spells, array reordered_by. + */ + public static function onSpellsReordered(): string + { + return 'featherpanel:admin:spells:reordered'; + } + /** * Callback: array import data, array results. */ diff --git a/backend/app/Services/Backup/BackupFifoEviction.php b/backend/app/Services/Backup/BackupFifoEviction.php index ef32836f2..44d5776f6 100644 --- a/backend/app/Services/Backup/BackupFifoEviction.php +++ b/backend/app/Services/Backup/BackupFifoEviction.php @@ -144,15 +144,33 @@ public static function evictOldestWingsBackup(int $serverId, string $serverUuid, } $response = $wings->getServer()->deleteBackup($serverUuid, (string) $victim['uuid']); + $missingOnNode = false; if (!$response->isSuccessful()) { - return [ - 'message' => 'Failed to remove oldest backup for rotation: ' . $response->getError(), - 'code' => 'FIFO_EVICTION_FAILED', - 'status' => $response->getStatusCode() >= 400 ? $response->getStatusCode() : 500, - ]; + $status = $response->getStatusCode(); + $error = $response->getError(); + if ($status === 404 && self::isMissingBackupOnNodeError($error)) { + $missingOnNode = true; + $app->getLogger()->warning('FIFO eviction found stale backup record ' . $victim['uuid'] . ' for server ' . $serverUuid . ': ' . $error); + } else { + return [ + 'message' => 'Failed to remove oldest backup for rotation: ' . $error, + 'code' => 'FIFO_EVICTION_FAILED', + 'status' => $status >= 400 ? $status : 500, + ]; + } } if (!Backup::deleteBackup((int) $victim['id'])) { + if ($missingOnNode) { + $app->getLogger()->error('FIFO eviction: stale backup ' . $victim['uuid'] . ' was missing on Wings but DB soft-delete failed'); + + return [ + 'message' => 'Backup was already missing on node but failed to update backup record', + 'code' => 'FIFO_EVICTION_DB_FAILED', + 'status' => 500, + ]; + } + $app->getLogger()->error('FIFO eviction: Wings deleted backup ' . $victim['uuid'] . ' but DB soft-delete failed'); return [ @@ -162,6 +180,12 @@ public static function evictOldestWingsBackup(int $serverId, string $serverUuid, ]; } + if ($missingOnNode) { + $app->getLogger()->info('FIFO backup rotation cleared stale Wings backup record ' . $victim['uuid'] . ' for server ' . $serverUuid); + + return null; + } + $app->getLogger()->info('FIFO backup rotation evicted Wings backup ' . $victim['uuid'] . ' for server ' . $serverUuid); return null; @@ -232,4 +256,14 @@ public static function evictOldestVmBackup(array $instance, Proxmox $client): ?a return null; } + + private static function isMissingBackupOnNodeError(string $error): bool + { + $normalized = strtolower($error); + + return str_contains($normalized, 'requested backup was not found') + || str_contains($normalized, 'backup was not found') + || str_contains($normalized, 'backup not found') + || str_contains($normalized, 'no such backup'); + } } diff --git a/backend/app/Services/Chatbot/ChatbotRuntime.php b/backend/app/Services/Chatbot/ChatbotRuntime.php new file mode 100644 index 000000000..89f17c471 --- /dev/null +++ b/backend/app/Services/Chatbot/ChatbotRuntime.php @@ -0,0 +1,97 @@ +. + */ + +namespace App\Services\Chatbot; + +class ChatbotRuntime +{ + public static function emit(?callable $emit, string $type, array $payload = []): void + { + if ($emit === null) { + return; + } + + $emit($type, $payload); + } + + public static function toolActivity(array $toolCall, array $toolResult, int $iteration): array + { + return [ + 'tool' => $toolCall['tool'] ?? 'unknown', + 'params' => self::sanitizeValue($toolCall['params'] ?? []), + 'success' => (bool) ($toolResult['success'] ?? false), + 'error' => $toolResult['error'] ?? null, + 'summary' => self::summarizeResult($toolResult), + 'iteration' => $iteration, + ]; + } + + public static function summarizeResult(array $toolResult): string + { + if (!($toolResult['success'] ?? false)) { + return (string) ($toolResult['error'] ?? 'Tool failed.'); + } + + $data = $toolResult['data'] ?? null; + if (is_array($data)) { + if (isset($data['message'])) { + return self::truncate((string) $data['message'], 240); + } + + if (isset($data['success'])) { + return ((bool) $data['success']) ? 'Tool completed successfully.' : 'Tool returned an unsuccessful result.'; + } + + return self::truncate(json_encode(self::sanitizeValue($data), JSON_UNESCAPED_SLASHES) ?: 'Tool completed.', 240); + } + + return self::truncate((string) $data, 240); + } + + public static function sanitizeValue(mixed $value): mixed + { + if (is_array($value)) { + $sanitized = []; + foreach ($value as $key => $item) { + $keyString = (string) $key; + if (preg_match('/password|token|secret|key|credential/i', $keyString)) { + $sanitized[$key] = '[redacted]'; + continue; + } + + $sanitized[$key] = self::sanitizeValue($item); + } + + return $sanitized; + } + + if (is_string($value)) { + return self::truncate($value, 500); + } + + return $value; + } + + public static function truncate(string $value, int $limit): string + { + if (strlen($value) <= $limit) { + return $value; + } + + return rtrim(substr($value, 0, $limit - 3)) . '...'; + } +} diff --git a/backend/app/Services/Chatbot/ChatbotService.php b/backend/app/Services/Chatbot/ChatbotService.php index bb1c0609a..9bc071b67 100755 --- a/backend/app/Services/Chatbot/ChatbotService.php +++ b/backend/app/Services/Chatbot/ChatbotService.php @@ -53,7 +53,7 @@ public function __construct() * * @return array Response with 'response' and 'model' keys */ - public function processMessage(string $message, array $history, array $user, array $pageContext = []): array + public function processMessage(string $message, array $history, array $user, array $pageContext = [], ?callable $emit = null): array { // Check if chatbot is enabled $enabled = $this->config->getSetting(ConfigInterface::CHATBOT_ENABLED, 'true'); @@ -69,22 +69,30 @@ public function processMessage(string $message, array $history, array $user, arr // Get chatbot configuration $temperature = (float) $this->config->getSetting(ConfigInterface::CHATBOT_TEMPERATURE, '0.7'); $maxTokens = (int) $this->config->getSetting(ConfigInterface::CHATBOT_MAX_TOKENS, '2048'); - $maxHistory = (int) $this->config->getSetting(ConfigInterface::CHATBOT_MAX_HISTORY, '10'); + $maxHistory = min((int) $this->config->getSetting(ConfigInterface::CHATBOT_MAX_HISTORY, '4'), 6); + + ChatbotRuntime::emit($emit, 'status', ['message' => 'Preparing chat context']); // Limit history to configured max $history = array_slice($history, -$maxHistory); + $isDashboardMode = $this->isDashboardMode($pageContext); + // Build comprehensive system prompt - $contextBuilder = new ContextBuilder(); + $contextBuilder = $isDashboardMode ? new DashboardContextBuilder() : new ContextBuilder(); // Load base system prompt from file - $baseSystemPrompt = ContextBuilder::loadSystemPrompt(); + $baseSystemPrompt = $isDashboardMode + ? DashboardContextBuilder::loadSystemPrompt() + : ContextBuilder::loadSystemPrompt(); // Get admin-configured system prompt (optional override) $adminSystemPrompt = $this->config->getSetting(ConfigInterface::CHATBOT_SYSTEM_PROMPT, ''); - // Build user context (servers, info, current page) - $userContext = $contextBuilder->buildContext($user, $pageContext); + // Build user context. Dashboard mode is compact and never injects logs by default. + $userContext = $isDashboardMode + ? $contextBuilder->buildContext($user, $pageContext) + : $contextBuilder->buildContext($user, $pageContext, $this->shouldIncludeLogs($message)); // Get conversation memory if available $conversationMemory = $pageContext['conversation_memory'] ?? ''; @@ -98,7 +106,8 @@ public function processMessage(string $message, array $history, array $user, arr // Add conversation memory if available if (!empty($conversationMemory)) { - $systemPrompt .= "\n\n## Conversation Memory\n{$conversationMemory}"; + $systemPrompt .= "\n\n## Compact Conversation Memory\n{$conversationMemory}"; + $systemPrompt .= "\n\nUse this compact memory only as background. Never infer resource IDs, current state, or tool parameters from older summary text when the current dashboard/server context or tools disagree."; } // Get admin-configured user prompt (optional) @@ -106,6 +115,9 @@ public function processMessage(string $message, array $history, array $user, arr // Prepend user prompt to message if configured $fullMessage = $message; + $fullMessage .= "\n\n[Current Turn Protocol: Treat this latest user message as the primary task. Use older conversation only as background. Do not bring up previous actions, previous questions, or unfinished older topics unless this latest message explicitly asks about them or clearly depends on them.]"; + $fullMessage .= "\n\n[Response Language Protocol: Reply in the same natural language as this latest user message. If this message is English, reply in English only, regardless of older conversation history. If earlier replies used the wrong language, acknowledge that plainly instead of denying it.]"; + $fullMessage .= "\n\n[Action Authorization Protocol: Only perform, claim, or emit ACTION/TOOL_CALL for destructive or state-changing actions when this latest user message explicitly requests that exact action. If the latest message asks to check, inspect, show status, diagnose, or look at a server, fetch/read status only. Do not restart, stop, kill, start, delete, write, or send console commands based on older conversation memory or summaries.]"; if (!empty($userPrompt)) { $fullMessage = "{$fullMessage}\n\n[User Context: {$userPrompt}]"; } @@ -140,28 +152,80 @@ public function processMessage(string $message, array $history, array $user, arr } // Initialize tool handler - $toolHandler = new ToolHandler(); + $toolHandler = new ToolHandler($isDashboardMode); // Add tool information to system prompt $toolsInfo = $this->formatToolsForPrompt($toolHandler); $systemPrompt .= "\n\n## Available Tools\n{$toolsInfo}"; - // Process message with tool calling support (max 3 iterations to avoid loops) - $maxToolIterations = 3; + // Process message with tool calling support. + $maxToolIterations = 5; $toolIterations = 0; $currentMessage = $fullMessage; $currentHistory = $history; $finalResponse = ''; $toolExecutions = []; // Store tool execution results for frontend + $toolActivity = []; + $usageItems = []; + $result = ['response' => '', 'model' => 'FeatherPanel AI']; + $requiredTool = $this->detectRequiredTool($message, $history); + $allowedSingleExecutionTools = $this->detectAllowedSingleExecutionTools($message, $history); + $lastToolResultsText = ''; + $toolOutcomeFallbacks = []; + $completedSingleExecutionTools = []; while ($toolIterations < $maxToolIterations) { // Process message through provider + ChatbotRuntime::emit($emit, 'status', [ + 'message' => $toolIterations === 0 ? 'Calling AI model' : 'Calling AI model with tool results', + 'iteration' => $toolIterations + 1, + ]); $result = $providerInstance->processMessage($currentMessage, $currentHistory, $systemPrompt); $response = $result['response']; + if (isset($result['usage']) && is_array($result['usage'])) { + $usageItems[] = $result['usage']; + ChatbotRuntime::emit($emit, 'usage', ['usage' => TokenUsage::aggregate($usageItems)]); + } // Check for tool calls + ChatbotRuntime::emit($emit, 'status', ['message' => 'Checking for tool calls']); $toolCalls = $toolHandler->parseToolCalls($response); + if (empty($toolCalls) && $toolHandler->hasMalformedToolCall($response)) { + $currentHistory[] = [ + 'role' => 'assistant', + 'content' => $toolHandler->removeToolCalls($response), + ]; + $currentMessage = $this->buildMalformedToolCorrection($toolHandler); + $currentHistory[] = [ + 'role' => 'user', + 'content' => $currentMessage, + ]; + ++$toolIterations; + $finalResponse = $toolHandler->removeToolCalls($response); + continue; + } + + if ( + empty($toolCalls) + && $requiredTool !== null + && $toolIterations < $maxToolIterations - 1 + && !$this->responseClaimsRequiredToolCompleted($response, $requiredTool) + ) { + $currentHistory[] = [ + 'role' => 'assistant', + 'content' => $toolHandler->removeToolCalls($response), + ]; + $currentMessage = $this->buildRequiredToolCorrection($requiredTool, $history, $pageContext); + $currentHistory[] = [ + 'role' => 'user', + 'content' => $currentMessage, + ]; + ++$toolIterations; + $finalResponse = $toolHandler->removeToolCalls($response); + continue; + } + if (empty($toolCalls)) { // No tool calls, return final response $finalResponse = $toolHandler->removeToolCalls($response); @@ -171,6 +235,27 @@ public function processMessage(string $message, array $history, array $user, arr // Execute tool calls $toolResults = []; foreach ($toolCalls as $toolCall) { + if (!$this->isToolAllowedForLatestIntent($toolCall, $allowedSingleExecutionTools)) { + $toolResults[] = [ + 'tool' => $toolCall['tool'], + 'result' => "Skipped {$toolCall['tool']} because it does not match the latest user request. Do not use tools from older conversation context; answer using only the tools that were actually run for this message.", + ]; + continue; + } + + if ($this->shouldSkipDuplicateToolCall($toolCall, $completedSingleExecutionTools)) { + $toolResults[] = [ + 'tool' => $toolCall['tool'], + 'result' => "Skipped duplicate {$toolCall['tool']} call because that action already completed during this message. Do not call it again; summarize the completed result.", + ]; + continue; + } + + ChatbotRuntime::emit($emit, 'tool_call', [ + 'tool' => $toolCall['tool'], + 'params' => ChatbotRuntime::sanitizeValue($toolCall['params']), + 'iteration' => $toolIterations + 1, + ]); $toolResult = $toolHandler->executeTool( $toolCall['tool'], $toolCall['params'], @@ -182,10 +267,24 @@ public function processMessage(string $message, array $history, array $user, arr if (is_array($toolResult['data']) && isset($toolResult['data']['action_type'])) { $toolExecutions[] = $toolResult['data']; } + $activity = ChatbotRuntime::toolActivity($toolCall, $toolResult, $toolIterations + 1); + $toolActivity[] = $activity; + ChatbotRuntime::emit($emit, 'tool_result', $activity); + + $formattedToolResult = $toolHandler->formatToolResult($toolCall['tool'], $toolResult); + if ($this->shouldGuaranteeToolOutcome($toolCall['tool'], $toolResult)) { + $toolOutcomeFallbacks[] = [ + 'tool' => $toolCall['tool'], + 'summary' => $formattedToolResult, + ]; + } + if (($toolResult['success'] ?? false) && $this->isSingleExecutionTool($toolCall['tool'])) { + $completedSingleExecutionTools[$toolCall['tool']] = true; + } $toolResults[] = [ 'tool' => $toolCall['tool'], - 'result' => $toolHandler->formatToolResult($toolCall['tool'], $toolResult), + 'result' => $formattedToolResult, ]; } @@ -197,10 +296,12 @@ public function processMessage(string $message, array $history, array $user, arr $toolResultsText .= "\nCRITICAL INSTRUCTIONS:\n"; $toolResultsText .= "- You MUST provide clear, specific feedback to the user about what happened\n"; $toolResultsText .= "- If an action succeeded, confirm what was done with specific details (e.g., 'I've created a backup named [backup_name] for server [server_name]')\n"; + $toolResultsText .= "- If create_database succeeded, include the database name, username, password, host, port, and type in the final answer\n"; $toolResultsText .= "- Include relevant information from the tool results (names, IDs, timestamps, etc.)\n"; $toolResultsText .= "- If an action failed, explain the error clearly\n"; $toolResultsText .= "- Never just say 'I'll do that' or 'done' without explaining what actually happened\n"; $toolResultsText .= '- Be conversational and helpful - the user wants to know what you did for them'; + $lastToolResultsText = $toolResultsText; // Remove tool calls from response and add to history $cleanResponse = $toolHandler->removeToolCalls($response); @@ -220,18 +321,37 @@ public function processMessage(string $message, array $history, array $user, arr $finalResponse = $cleanResponse; // Store in case we hit max iterations } - // If we still have tool calls after max iterations, append a note + // If the last model turn still tried to call a tool, force one final synthesis pass + // so successful tool output is translated into a clean answer for the user. if ($toolIterations >= $maxToolIterations) { - $remainingCalls = $toolHandler->parseToolCalls($response); + $remainingCalls = $toolHandler->parseToolCalls($result['response'] ?? ''); if (!empty($remainingCalls)) { - $finalResponse .= "\n\n[Note: Maximum tool call iterations reached. Some tools may not have been executed.]"; + if ($lastToolResultsText !== '') { + ChatbotRuntime::emit($emit, 'status', ['message' => 'Preparing final tool result summary']); + $finalSystemPrompt = $systemPrompt . "\n\n## Final Tool Result Synthesis\n" + . 'Do not call any tools in this pass. Use only the supplied tool results and write the final user-facing answer.'; + $finalMessage = $lastToolResultsText . "\n\nNo more tools may be called. Summarize the completed tool results for the user now."; + $synthesisResult = $providerInstance->processMessage($finalMessage, $currentHistory, $finalSystemPrompt); + if (isset($synthesisResult['usage']) && is_array($synthesisResult['usage'])) { + $usageItems[] = $synthesisResult['usage']; + ChatbotRuntime::emit($emit, 'usage', ['usage' => TokenUsage::aggregate($usageItems)]); + } + $finalResponse = $toolHandler->removeToolCalls($synthesisResult['response'] ?? $finalResponse); + $result = $synthesisResult + $result; + } else { + $finalResponse .= "\n\n[Note: Maximum tool call iterations reached. Some tools may not have been executed.]"; + } } } + $finalResponse = $this->appendMissingToolOutcomes($finalResponse, $toolOutcomeFallbacks); + return [ 'response' => trim($finalResponse), 'model' => $result['model'] ?? 'FeatherPanel AI', 'tool_executions' => $toolExecutions, // Include tool execution results for frontend + 'tool_activity' => $toolActivity, + 'usage' => TokenUsage::aggregate($usageItems), ]; } @@ -245,30 +365,317 @@ public function processMessage(string $message, array $history, array $user, arr private function formatToolsForPrompt(ToolHandler $toolHandler): string { $tools = $toolHandler->getAvailableTools(); - $text = "You have access to the following tools to retrieve real-time data and perform actions:\n\n"; + $text = "Use TOOL_CALL only when real-time data or an action is needed. Available tools:\n\n"; foreach ($tools as $tool) { - $text .= "### {$tool['name']}\n"; - $text .= "Description: {$tool['description']}\n"; - $text .= "Parameters:\n"; - foreach ($tool['parameters'] as $param => $desc) { - $text .= " - {$param}: {$desc}\n"; + $parameters = []; + foreach ($tool['parameters'] as $param => $description) { + $parameters[] = "{$param}: {$description}"; } - $text .= "\n"; - $text .= "To use this tool, include in your response:\n"; - $text .= "TOOL_CALL: {$tool['name']} {\"param1\": \"value1\", \"param2\": \"value2\"}\n\n"; + $text .= "- {$tool['name']}(" . implode('; ', $parameters) . ")\n"; } - $text .= "IMPORTANT:\n"; - $text .= "- Use tools when you need real-time data (e.g., server activities, database credentials)\n"; - $text .= "- Do NOT include all data in your initial response - use tools to fetch what's needed\n"; - $text .= "- You can call multiple tools in one response\n"; - $text .= "- Tool results will be provided in your next context\n"; - $text .= "- Always provide a natural language response along with tool calls\n"; + $text .= "\nFormat: TOOL_CALL: tool_name {\"param\": \"value\"}\n"; + $text .= "TOOL_CALL always requires a valid JSON object after the tool name. Never write TOOL_CALL for navigation.\n"; + $text .= "For navigation, use ACTION: navigate server [uuid_or_name] to [page].\n"; + $text .= "Tool selection protocol: if a tool exists for the user's latest request, use that tool instead of navigating or describing the page. Examples: create database -> create_database, create backup -> create_backup, create schedule -> create_schedule, list status -> get_server_status, list databases/credentials -> get_database_credentials.\n"; + $text .= "You may call multiple tools. Always include a short natural language response with tool calls.\n"; return $text; } + private function buildMalformedToolCorrection(ToolHandler $toolHandler): string + { + $toolNames = implode(', ', array_keys($toolHandler->getAvailableTools())); + + return "Your previous response contained invalid TOOL_CALL syntax. Regenerate the answer now.\n" + . "Rules:\n" + . "- TOOL_CALL format is exactly: TOOL_CALL: tool_name {\"param\":\"value\"}\n" + . "- TOOL_CALL must use one of these tool names: {$toolNames}\n" + . "- Never use TOOL_CALL for navigation. Navigation uses ACTION: navigate server [uuid_or_name] to [page]\n" + . "- If a tool exists for the latest user request, call it with valid JSON parameters.\n" + . '- Do not expose TOOL_CALL text unless it is valid and intended for execution.'; + } + + private function detectRequiredTool(string $message, array $history): ?string + { + $message = mb_strtolower($message); + $hasRecentDatabaseContext = $this->hasRecentDatabaseContext($history); + + if ( + preg_match('/\b(delete|remove|drop)\b.*\b(database|db)\b/u', $message) + || ( + $hasRecentDatabaseContext + && preg_match('/\b(delete|remove|drop)\s+(it|that|this|one)\b/u', $message) + ) + ) { + return 'delete_database'; + } + + if ( + preg_match('/\b(create|make|add|new|setup|set\s+up)\b.*\b(database|db)\b/u', $message) + || ( + $hasRecentDatabaseContext + && preg_match('/\b(create|make|add|setup|set\s+up)\b.*\b(one|it|that|this)\b/u', $message) + ) + ) { + return 'create_database'; + } + + if ( + preg_match('/\b(update|change|modify)\b.*\b(database|db)\b/u', $message) + || ( + $hasRecentDatabaseContext + && preg_match('/\b(update|change|modify)\s+(it|that|this|one)\b/u', $message) + ) + ) { + return 'update_database'; + } + + return null; + } + + private function hasRecentDatabaseContext(array $history): bool + { + $recentHistory = array_slice($history, -6); + $historyText = mb_strtolower(json_encode($recentHistory, JSON_UNESCAPED_SLASHES) ?: ''); + + return (bool) preg_match('/\b(databases?|db|database_credentials|create_database|delete_database|update_database)\b/u', $historyText); + } + + private function responseClaimsRequiredToolCompleted(string $response, string $tool): bool + { + if (str_contains($response, "TOOL_CALL: {$tool}")) { + return true; + } + + return false; + } + + private function shouldGuaranteeToolOutcome(string $toolName, array $toolResult): bool + { + if (!($toolResult['success'] ?? false)) { + return true; + } + + $data = $toolResult['data'] ?? null; + if (!is_array($data)) { + return false; + } + + if ($toolName === 'create_database' && (($data['success'] ?? false) === true)) { + return true; + } + + return isset($data['action_type']); + } + + private function shouldSkipDuplicateToolCall(array $toolCall, array $completedSingleExecutionTools): bool + { + $toolName = (string) ($toolCall['tool'] ?? ''); + + return $this->isSingleExecutionTool($toolName) && isset($completedSingleExecutionTools[$toolName]); + } + + private function isToolAllowedForLatestIntent(array $toolCall, array $allowedSingleExecutionTools): bool + { + $toolName = (string) ($toolCall['tool'] ?? ''); + + if (!$this->isSingleExecutionTool($toolName) || empty($allowedSingleExecutionTools)) { + return true; + } + + return in_array($toolName, $allowedSingleExecutionTools, true); + } + + private function detectAllowedSingleExecutionTools(string $message, array $history): array + { + $message = mb_strtolower($message); + $historyText = mb_strtolower(json_encode(array_slice($history, -6), JSON_UNESCAPED_SLASHES) ?: ''); + $isCreate = (bool) preg_match('/\b(create|make|add|new|setup|set\s+up)\b/u', $message); + $isDelete = (bool) preg_match('/\b(delete|remove|drop|destroy)\b/u', $message); + $isUpdate = (bool) preg_match('/\b(update|change|modify|edit|rename)\b/u', $message); + + if (!$isCreate && !$isDelete && !$isUpdate) { + return []; + } + + $hasContext = static function (string $pattern) use ($message, $historyText): bool { + return (bool) preg_match($pattern, $message) || (bool) preg_match($pattern, $historyText); + }; + + if ($hasContext('/\b(databases?|db|database_credentials|create_database|delete_database|update_database)\b/u')) { + return $isDelete ? ['delete_database'] : ($isUpdate ? ['update_database'] : ['create_database']); + } + + if ($hasContext('/\b(backups?|create_backup|delete_backup)\b/u')) { + return $isDelete ? ['delete_backup'] : ['create_backup']; + } + + if ($hasContext('/\b(schedules?|tasks?|create_schedule|delete_schedule|update_schedule|create_task|delete_task|update_task)\b/u')) { + if ($isDelete) { + return ['delete_schedule', 'delete_task']; + } + + return $isUpdate ? ['update_schedule', 'update_task'] : ['create_schedule', 'create_task']; + } + + if ($hasContext('/\b(subdomains?|domains?|create_subdomain|delete_subdomain)\b/u')) { + return $isDelete ? ['delete_subdomain'] : ['create_subdomain']; + } + + if ($hasContext('/\b(allocations?|ports?|delete_allocation|set_primary_allocation|auto_allocate)\b/u')) { + return $isDelete ? ['delete_allocation'] : ['set_primary_allocation', 'auto_allocate']; + } + + if ($hasContext('/\b(files?|folders?|directories?|write_file|delete_files|rename_file|copy_files|compress_files|decompress_archive|pull_file)\b/u')) { + if ($isDelete) { + return ['delete_files']; + } + + return ['write_file', 'create_directory', 'rename_file', 'copy_files', 'compress_files', 'decompress_archive', 'pull_file']; + } + + return []; + } + + private function isSingleExecutionTool(string $toolName): bool + { + return in_array($toolName, [ + 'create_database', + 'delete_database', + 'update_database', + 'create_backup', + 'delete_backup', + 'server_power_action', + 'create_schedule', + 'delete_schedule', + 'update_schedule', + 'create_subdomain', + 'delete_subdomain', + 'delete_allocation', + 'set_primary_allocation', + 'auto_allocate', + 'write_file', + 'create_directory', + 'delete_files', + 'rename_file', + 'copy_files', + 'compress_files', + 'decompress_archive', + 'pull_file', + ], true); + } + + private function appendMissingToolOutcomes(string $response, array $fallbacks): string + { + $response = trim($response); + + foreach ($fallbacks as $fallback) { + $summary = trim((string) ($fallback['summary'] ?? '')); + if ($summary === '') { + continue; + } + + if ($this->summaryContainsDatabaseCredentials($summary) && !$this->responseCoversToolOutcome($response, $summary)) { + $response = "The database was created successfully. Here are the actual credentials returned by the tool:\n\n" . $summary; + continue; + } + + if ($this->responseCoversToolOutcome($response, $summary)) { + continue; + } + + $summary = ChatbotRuntime::truncate($summary, 2500); + $response = $response === '' ? $summary : $response . "\n\n" . $summary; + } + + return $response; + } + + private function responseCoversToolOutcome(string $response, string $summary): bool + { + if ($response === '') { + return false; + } + + $outcomeValues = $this->extractOutcomeValues($summary); + foreach ($outcomeValues as $value) { + if (mb_strlen($value) >= 3 && str_contains(mb_strtolower($response), mb_strtolower($value))) { + return true; + } + } + + if (!empty($outcomeValues)) { + return false; + } + + $normalizedResponse = mb_strtolower($response); + if (preg_match('/\b(successfully|completed|created|deleted|updated|started|stopped|restarted|renamed|copied|compressed|extracted|written|initiated)\b/u', $normalizedResponse)) { + return true; + } + + return false; + } + + private function summaryContainsDatabaseCredentials(string $text): bool + { + return (bool) preg_match('/\bDatabase Name:\s*\S+.*\bUsername:\s*\S+.*\bPassword:\s*\S+/is', $text); + } + + private function extractOutcomeValues(string $summary): array + { + preg_match_all('/^[A-Za-z][A-Za-z ]+:\s*(.+)$/m', $summary, $matches); + + return array_values(array_filter(array_map(static function (string $value): string { + return trim($value); + }, $matches[1] ?? []))); + } + + private function buildRequiredToolCorrection(string $tool, array $history, array $pageContext): string + { + $historyText = substr(json_encode(array_slice($history, -6), JSON_UNESCAPED_SLASHES) ?: '', -6000); + $contextText = substr(json_encode($pageContext, JSON_UNESCAPED_SLASHES) ?: '', -2000); + + return "The latest user message requires the {$tool} tool, but your previous answer did not call it.\n" + . "Regenerate the answer now and call the required tool using valid JSON.\n" + . "Use recent history/tool results to resolve references like 'it', 'that database', or a friendly name such as 'hide and seek'.\n" + . "Recent history: {$historyText}\n" + . "Current page context: {$contextText}\n" + . "Required format: TOOL_CALL: {$tool} {\"server_uuid\":\"...\",\"database_name\":\"...\"}\n" + . 'If the exact database name is known from history, pass it. If only a friendly name is known, pass that as database_name so the tool can fuzzy-match it.'; + } + + /** + * Decide whether the latest user message needs server logs in context. + */ + private function shouldIncludeLogs(string $message): bool + { + $message = mb_strtolower($message); + + return (bool) preg_match( + '/\b(logs?|console|error|errors|crash(?:ed|es|ing)?|exception|stacktrace|traceback|failed?|failure|debug|diagnos(?:e|is)|troubleshoot|issue|problem|broken|boot|startup|start(?:ing)?|won[’\']?t\s+(?:start|boot)|not\s+(?:start(?:ing)?|boot(?:ing)?|working))\b/u', + $message + ); + } + + private function isDashboardMode(array $pageContext): bool + { + if (($pageContext['mode'] ?? '') !== 'dashboard') { + return false; + } + + $route = (string) ($pageContext['route'] ?? $pageContext['page'] ?? ''); + $allowedRoutes = [ + '/dashboard', + '/dashboard/servers', + '/dashboard/vms', + '/dashboard/knowledgebase', + ]; + + return in_array($route, $allowedRoutes, true) || str_starts_with($route, '/dashboard/knowledgebase/'); + } + /** * Get the appropriate provider instance based on configuration. * diff --git a/backend/app/Services/Chatbot/ContextBuilder.php b/backend/app/Services/Chatbot/ContextBuilder.php index bbf8b63c8..8a6ea0fce 100755 --- a/backend/app/Services/Chatbot/ContextBuilder.php +++ b/backend/app/Services/Chatbot/ContextBuilder.php @@ -43,10 +43,11 @@ public function __construct() * * @param array $user Current user data * @param array $pageContext Current page context (route, server, etc.) + * @param bool $includeLogs Whether server logs should be included in context * * @return string Formatted context string */ - public function buildContext(array $user, array $pageContext = []): string + public function buildContext(array $user, array $pageContext = [], bool $includeLogs = false): string { $context = []; @@ -67,10 +68,6 @@ public function buildContext(array $user, array $pageContext = []): string $context[] = "User UUID: {$user['uuid']}"; $context[] = "User ID: {$user['id']}"; - if (isset($user['email'])) { - $context[] = "Email: {$user['email']}"; - } - if (isset($user['first_name']) || isset($user['last_name'])) { $name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? '')); if (!empty($name)) { @@ -78,18 +75,6 @@ public function buildContext(array $user, array $pageContext = []): string } } - if (isset($user['avatar']) && !empty($user['avatar'])) { - $context[] = "Avatar: {$user['avatar']}"; - } - - if (isset($user['two_fa_enabled'])) { - $context[] = '2FA Enabled: ' . ($user['two_fa_enabled'] === 'true' ? 'Yes' : 'No'); - } - - if (isset($user['last_seen'])) { - $context[] = "Last Seen: {$user['last_seen']}"; - } - // User Role/Permissions if ($isAdmin) { $context[] = 'Role: Administrator (Full Access)'; @@ -120,78 +105,12 @@ public function buildContext(array $user, array $pageContext = []): string $context[] = "- UUID: {$server['uuidShort']}"; $context[] = '- Status: ' . ($server['status'] ?? 'unknown'); - if (isset($server['description']) && !empty($server['description'])) { - $context[] = "- Description: {$server['description']}"; - } - - // Only include node/realm/spell info if user is admin or has access - if ($isAdmin || isset($server['node']['name'])) { - if (isset($server['node']['name'])) { - $context[] = "- Node: {$server['node']['name']}"; - } - } - - if (isset($server['realm']['name'])) { - $context[] = "- Realm: {$server['realm']['name']}"; - } - if (isset($server['spell']['name'])) { $context[] = "- Spell/Type: {$server['spell']['name']}"; } - // Server Resource Limits - if (isset($server['memory'])) { - $memoryMB = (int) $server['memory']; - $memoryGB = round($memoryMB / 1024, 2); - $context[] = "- Memory Limit: {$memoryMB} MB ({$memoryGB} GB)"; - } - - if (isset($server['swap'])) { - $swapMB = (int) $server['swap']; - $swapGB = round($swapMB / 1024, 2); - $context[] = "- Swap Limit: {$swapMB} MB ({$swapGB} GB)"; - } - - if (isset($server['disk'])) { - $diskMB = (int) $server['disk']; - $diskGB = round($diskMB / 1024, 2); - $context[] = "- Disk Limit: {$diskMB} MB ({$diskGB} GB)"; - } - - if (isset($server['cpu'])) { - $context[] = "- CPU Limit: {$server['cpu']}%"; - } - - if (isset($server['io'])) { - $context[] = "- IO Limit: {$server['io']}"; - } - - // Allocation Information (IP and Port) - if (isset($server['allocation'])) { - $allocation = $server['allocation']; - if (isset($allocation['ip'])) { - $ipInfo = $allocation['ip']; - if (isset($allocation['ip_alias']) && !empty($allocation['ip_alias'])) { - $ipInfo .= " (Alias: {$allocation['ip_alias']})"; - } - $context[] = "- IP Address: {$ipInfo}"; - } - if (isset($allocation['port'])) { - $context[] = "- Port: {$allocation['port']}"; - // Show connection info if both IP and port are available - if (isset($allocation['ip'])) { - $context[] = "- Connection: {$allocation['ip']}:{$allocation['port']}"; - } - } - } - if (isset($server['is_subuser']) && $server['is_subuser']) { $context[] = '- Access: Subuser (Limited Permissions)'; - // Only include specific permissions if user is admin - if ($isAdmin && isset($server['subuser_permissions']) && !empty($server['subuser_permissions'])) { - $perms = implode(', ', array_slice($server['subuser_permissions'], 0, 5)); // Limit to 5 - $context[] = "- Permissions: {$perms}"; - } } else { $context[] = '- Access: Owner (Full Control)'; } @@ -323,24 +242,16 @@ public function buildContext(array $user, array $pageContext = []): string $context[] = "Docker Image: {$serverData['image']}"; } - // Fetch server logs if: - // 1. Server is running or starting (to see current activity) - // 2. User is on logs page (to see logs regardless of status) - // 3. User is on console page (to see console output) - $shouldFetchLogs = in_array(strtolower($serverStatus), ['running', 'starting', 'stopping', 'stopped']); - $isOnLogsPage = isset($pageContext['page']) && in_array(strtolower($pageContext['page']), ['logs', 'console']); - - if ($shouldFetchLogs || $isOnLogsPage) { + if ($includeLogs) { $serverLogs = $this->getServerLogs($serverData); if (!empty($serverLogs)) { $context[] = ''; $context[] = '### Recent Server Logs'; - $context[] = 'The following are the most recent server logs (last 50 lines):'; + $context[] = 'The following are the most recent server logs (last 20 lines):'; $context[] = ''; $context[] = '```'; - // Limit to last 50 lines to avoid token limits - $logLines = is_array($serverLogs) ? array_slice($serverLogs, -50) : explode("\n", $serverLogs); - $logLines = array_slice($logLines, -50); + $logLines = is_array($serverLogs) ? array_slice($serverLogs, -20) : explode("\n", $serverLogs); + $logLines = array_slice($logLines, -20); $context[] = implode("\n", $logLines); $context[] = '```'; } @@ -409,8 +320,8 @@ private function getUserServers(int $userId): array // Combine and enrich with related data $allServers = array_merge($ownedServers, $subuserServers); - // Limit to 20 most recent to avoid token limits - $allServers = array_slice($allServers, 0, 20); + // Keep context compact; tools can fetch deeper data on demand. + $allServers = array_slice($allServers, 0, 8); foreach ($allServers as &$server) { // Check if subuser diff --git a/backend/app/Services/Chatbot/DashboardContextBuilder.php b/backend/app/Services/Chatbot/DashboardContextBuilder.php new file mode 100644 index 000000000..4a995c527 --- /dev/null +++ b/backend/app/Services/Chatbot/DashboardContextBuilder.php @@ -0,0 +1,176 @@ +. + */ + +namespace App\Services\Chatbot; + +use App\App; +use App\Chat\Spell; +use App\Chat\Server; +use App\Permissions; +use App\Chat\Subuser; +use App\Chat\VmInstance; +use App\Helpers\PermissionHelper; + +class DashboardContextBuilder +{ + private $app; + + public function __construct() + { + $this->app = App::getInstance(true); + } + + public function buildContext(array $user, array $pageContext = []): string + { + $context = []; + $userUuid = $user['uuid'] ?? ''; + $isAdmin = PermissionHelper::hasPermission($userUuid, Permissions::ADMIN_ROOT); + + $context[] = '## User Information'; + $context[] = 'Username: ' . ($user['username'] ?? 'unknown'); + $context[] = 'User UUID: ' . ($user['uuid'] ?? 'unknown'); + $context[] = 'User ID: ' . ($user['id'] ?? 'unknown'); + $context[] = $isAdmin ? 'Role: Administrator (Full Access)' : 'Role: User'; + + if (!empty($pageContext)) { + $context[] = ''; + $context[] = '## Current Dashboard Page'; + $context[] = 'Route: ' . ($pageContext['route'] ?? 'unknown'); + $context[] = 'Page: ' . ($pageContext['page'] ?? $pageContext['routeName'] ?? 'unknown'); + } + + $servers = $this->getUserServers((int) ($user['id'] ?? 0)); + $context[] = ''; + $context[] = "## User's Servers"; + if (empty($servers)) { + $context[] = 'No servers were found for this user.'; + } else { + $context[] = 'Showing up to 8 accessible servers. Use tools for details.'; + foreach ($servers as $index => $server) { + $context[] = sprintf( + '%d. %s (%s) - status: %s, type: %s, access: %s', + $index + 1, + $server['name'] ?? 'Unnamed server', + $server['uuidShort'] ?? $server['uuid'] ?? 'unknown', + $server['status'] ?? 'unknown', + $server['spell']['name'] ?? 'unknown', + !empty($server['is_subuser']) ? 'subuser' : 'owner' + ); + } + } + + $instances = $this->getUserVdsInstances($userUuid); + $context[] = ''; + $context[] = "## User's VDS Instances"; + if (empty($instances)) { + $context[] = 'No VDS instances were found for this user.'; + } else { + $context[] = 'Showing up to 5 accessible VDS instances. Use tools for details.'; + foreach ($instances as $index => $instance) { + $context[] = sprintf( + '%d. %s (ID: %s) - status: %s, type: %s', + $index + 1, + $instance['hostname'] ?? 'Unnamed VDS', + $instance['id'] ?? 'unknown', + $instance['status'] ?? 'unknown', + $instance['vm_type'] ?? 'unknown' + ); + } + } + + $context[] = ''; + $context[] = '## Context Limits'; + $context[] = 'Logs, files, credentials, backups, allocations, full resource specs, and knowledgebase articles are not included by default.'; + $context[] = 'Use tools only when the user asks for specific details or knowledgebase help.'; + + return implode("\n", $context); + } + + public static function loadSystemPrompt(): string + { + $promptFile = __DIR__ . '/dashboard-system-prompt.txt'; + + if (file_exists($promptFile)) { + $content = file_get_contents($promptFile); + + return trim($content); + } + + return 'You are FeatherPanel Dashboard AI. Help users understand and navigate their dashboard, servers, VDS instances, and knowledgebase while keeping answers concise and using tools for specific details.'; + } + + private function getUserServers(int $userId): array + { + if ($userId <= 0) { + return []; + } + + try { + $ownedServers = Server::searchServers(page: 1, limit: 8, search: '', ownerId: $userId); + $subusers = Subuser::getSubusersByUserId($userId); + $subuserMap = []; + $subuserServers = []; + + foreach ($subusers as $subuser) { + $serverId = (int) ($subuser['server_id'] ?? 0); + if ($serverId <= 0 || isset($subuserMap[$serverId])) { + continue; + } + + $subuserMap[$serverId] = $subuser; + $server = Server::getServerById($serverId); + if ($server) { + $subuserServers[] = $server; + } + } + + $servers = array_slice(array_merge($ownedServers, $subuserServers), 0, 8); + foreach ($servers as &$server) { + $serverId = (int) ($server['id'] ?? 0); + $server['is_subuser'] = isset($subuserMap[$serverId]); + $spell = isset($server['spell_id']) ? Spell::getSpellById((int) $server['spell_id']) : null; + $server['spell'] = [ + 'name' => $spell['name'] ?? null, + ]; + } + unset($server); + + return $servers; + } catch (\Exception $e) { + $this->app->getLogger()->error('DashboardContextBuilder: Failed to get servers: ' . $e->getMessage()); + + return []; + } + } + + private function getUserVdsInstances(string $userUuid): array + { + if ($userUuid === '') { + return []; + } + + try { + $instances = VmInstance::getByUserUuid($userUuid, 1, 5); + + return is_array($instances) ? $instances : []; + } catch (\Exception $e) { + $this->app->getLogger()->error('DashboardContextBuilder: Failed to get VDS instances: ' . $e->getMessage()); + + return []; + } + } +} diff --git a/backend/app/Services/Chatbot/Providers/BasicProvider.php b/backend/app/Services/Chatbot/Providers/BasicProvider.php index 8bae1a2ee..b2831372c 100755 --- a/backend/app/Services/Chatbot/Providers/BasicProvider.php +++ b/backend/app/Services/Chatbot/Providers/BasicProvider.php @@ -17,6 +17,8 @@ namespace App\Services\Chatbot\Providers; +use App\Services\Chatbot\TokenUsage; + class BasicProvider implements ProviderInterface { /** @@ -33,48 +35,42 @@ public function processMessage(string $message, array $history, string $systemPr $lowerMessage = strtolower($message); if (strpos($lowerMessage, 'hello') !== false || strpos($lowerMessage, 'hi') !== false) { - return [ - 'response' => "Hello! I'm your AI assistant for FeatherPanel. How can I help you today?", - 'model' => 'FeatherPanel AI', - ]; + return $this->response($message, "Hello! I'm your AI assistant for FeatherPanel. How can I help you today?"); } if (strpos($lowerMessage, 'help') !== false) { - return [ - 'response' => "I can help you with various FeatherPanel tasks:\n\n" . - "• Server management\n" . - "• Configuration questions\n" . - "• General panel information\n" . - "• Troubleshooting\n\n" . - 'What would you like to know?', - 'model' => 'FeatherPanel AI', - ]; + return $this->response($message, "I can help you with various FeatherPanel tasks:\n\n" . + "• Server management\n" . + "• Configuration questions\n" . + "• General panel information\n" . + "• Troubleshooting\n\n" . + 'What would you like to know?'); } if (strpos($lowerMessage, 'server') !== false) { - return [ - 'response' => "I can help you with server-related tasks. You can:\n\n" . - "• View server status\n" . - "• Manage server files\n" . - "• Control server power (start/stop/restart)\n" . - "• View server console\n" . - "• Manage databases\n\n" . - 'What specific server task do you need help with?', - 'model' => 'FeatherPanel AI', - ]; + return $this->response($message, "I can help you with server-related tasks. You can:\n\n" . + "• View server status\n" . + "• Manage server files\n" . + "• Control server power (start/stop/restart)\n" . + "• View server console\n" . + "• Manage databases\n\n" . + 'What specific server task do you need help with?'); } if (strpos($lowerMessage, 'thank') !== false) { - return [ - 'response' => "You're welcome! Is there anything else I can help you with?", - 'model' => 'FeatherPanel AI', - ]; + return $this->response($message, "You're welcome! Is there anything else I can help you with?"); } + return $this->response($message, "I understand you're asking about: " . $message . "\n\n" . + "I'm a basic assistant. For more advanced responses, please configure Google Gemini, OpenRouter, or OpenAI in admin settings."); + } + + private function response(string $input, string $output): array + { return [ - 'response' => "I understand you're asking about: " . $message . "\n\n" . - "I'm a basic assistant. For more advanced responses, please configure Google Gemini, OpenRouter, or OpenAI in admin settings.", + 'response' => $output, 'model' => 'FeatherPanel AI', + 'usage' => TokenUsage::estimate($input, $output), ]; } } diff --git a/backend/app/Services/Chatbot/Providers/GoogleGeminiProvider.php b/backend/app/Services/Chatbot/Providers/GoogleGeminiProvider.php index 89567e550..cccfd1b80 100755 --- a/backend/app/Services/Chatbot/Providers/GoogleGeminiProvider.php +++ b/backend/app/Services/Chatbot/Providers/GoogleGeminiProvider.php @@ -19,6 +19,7 @@ use App\App; use GuzzleHttp\Client; +use App\Services\Chatbot\TokenUsage; use GuzzleHttp\Exception\GuzzleException; class GoogleGeminiProvider implements ProviderInterface @@ -155,6 +156,7 @@ public function processMessage(string $message, array $history, string $systemPr return [ 'response' => $responseText, 'model' => "Google Gemini {$this->model}", + 'usage' => TokenUsage::fromGeminiUsage($data['usageMetadata'] ?? null, $message, $responseText), ]; } catch (GuzzleException $e) { $this->app->getLogger()->error('Google Gemini API exception: ' . $e->getMessage()); diff --git a/backend/app/Services/Chatbot/Providers/GrokProvider.php b/backend/app/Services/Chatbot/Providers/GrokProvider.php index 5290cbe16..2cee266eb 100755 --- a/backend/app/Services/Chatbot/Providers/GrokProvider.php +++ b/backend/app/Services/Chatbot/Providers/GrokProvider.php @@ -19,6 +19,7 @@ use App\App; use GuzzleHttp\Client; +use App\Services\Chatbot\TokenUsage; use GuzzleHttp\Exception\GuzzleException; class GrokProvider implements ProviderInterface @@ -132,6 +133,7 @@ public function processMessage(string $message, array $history, string $systemPr return [ 'response' => $responseText, 'model' => "Grok {$this->model}", + 'usage' => TokenUsage::fromOpenAiUsage($data['usage'] ?? null, $message, $responseText), ]; } catch (GuzzleException $e) { $this->app->getLogger()->error('xAI (Grok) API exception: ' . $e->getMessage()); diff --git a/backend/app/Services/Chatbot/Providers/OllamaProvider.php b/backend/app/Services/Chatbot/Providers/OllamaProvider.php index 7c8b2b656..0dde7503b 100755 --- a/backend/app/Services/Chatbot/Providers/OllamaProvider.php +++ b/backend/app/Services/Chatbot/Providers/OllamaProvider.php @@ -19,6 +19,7 @@ use App\App; use GuzzleHttp\Client; +use App\Services\Chatbot\TokenUsage; use GuzzleHttp\Exception\GuzzleException; class OllamaProvider implements ProviderInterface @@ -134,10 +135,21 @@ public function processMessage(string $message, array $history, string $systemPr } $responseText = $data['response']; + $usage = [ + 'input_tokens' => isset($data['prompt_eval_count']) ? (int) $data['prompt_eval_count'] : TokenUsage::estimateTextTokens($prompt), + 'output_tokens' => isset($data['eval_count']) ? (int) $data['eval_count'] : TokenUsage::estimateTextTokens($responseText), + 'source' => isset($data['prompt_eval_count'], $data['eval_count']) ? 'provider' : 'estimated', + 'raw' => [ + 'prompt_eval_count' => $data['prompt_eval_count'] ?? null, + 'eval_count' => $data['eval_count'] ?? null, + ], + ]; + $usage['total_tokens'] = $usage['input_tokens'] + $usage['output_tokens']; return [ 'response' => $responseText, 'model' => "Ollama {$this->model}", + 'usage' => $usage, ]; } catch (GuzzleException $e) { $this->app->getLogger()->error('Ollama API exception: ' . $e->getMessage()); diff --git a/backend/app/Services/Chatbot/Providers/OpenAIProvider.php b/backend/app/Services/Chatbot/Providers/OpenAIProvider.php index dc405ad1d..7f422293e 100755 --- a/backend/app/Services/Chatbot/Providers/OpenAIProvider.php +++ b/backend/app/Services/Chatbot/Providers/OpenAIProvider.php @@ -20,6 +20,7 @@ use App\App; use GuzzleHttp\Client; use App\Config\ConfigInterface; +use App\Services\Chatbot\TokenUsage; use GuzzleHttp\Exception\GuzzleException; class OpenAIProvider implements ProviderInterface @@ -138,6 +139,7 @@ public function processMessage(string $message, array $history, string $systemPr return [ 'response' => $responseText, 'model' => "OpenAI {$this->model}", + 'usage' => TokenUsage::fromOpenAiUsage($data['usage'] ?? null, $message, $responseText), ]; } catch (GuzzleException $e) { $this->app->getLogger()->error('OpenAI API exception: ' . $e->getMessage()); diff --git a/backend/app/Services/Chatbot/Providers/OpenRouterProvider.php b/backend/app/Services/Chatbot/Providers/OpenRouterProvider.php index 1b646d987..c570612d8 100755 --- a/backend/app/Services/Chatbot/Providers/OpenRouterProvider.php +++ b/backend/app/Services/Chatbot/Providers/OpenRouterProvider.php @@ -19,6 +19,7 @@ use App\App; use GuzzleHttp\Client; +use App\Services\Chatbot\TokenUsage; use GuzzleHttp\Exception\GuzzleException; class OpenRouterProvider implements ProviderInterface @@ -137,6 +138,7 @@ public function processMessage(string $message, array $history, string $systemPr return [ 'response' => $responseText, 'model' => "OpenRouter {$modelName}", + 'usage' => TokenUsage::fromOpenAiUsage($data['usage'] ?? null, $message, $responseText), ]; } catch (GuzzleException $e) { $this->app->getLogger()->error('OpenRouter API exception: ' . $e->getMessage()); diff --git a/backend/app/Services/Chatbot/Providers/PerplexityProvider.php b/backend/app/Services/Chatbot/Providers/PerplexityProvider.php index 0d0364c60..ff1ce9997 100755 --- a/backend/app/Services/Chatbot/Providers/PerplexityProvider.php +++ b/backend/app/Services/Chatbot/Providers/PerplexityProvider.php @@ -19,6 +19,7 @@ use App\App; use GuzzleHttp\Client; +use App\Services\Chatbot\TokenUsage; use GuzzleHttp\Exception\GuzzleException; class PerplexityProvider implements ProviderInterface @@ -135,6 +136,7 @@ public function processMessage(string $message, array $history, string $systemPr return [ 'response' => $responseText, 'model' => "Perplexity {$this->model}", + 'usage' => TokenUsage::fromOpenAiUsage($data['usage'] ?? null, $message, $responseText), ]; } catch (GuzzleException $e) { $this->app->getLogger()->error('Perplexity API exception: ' . $e->getMessage()); diff --git a/backend/app/Services/Chatbot/Providers/ProviderInterface.php b/backend/app/Services/Chatbot/Providers/ProviderInterface.php index 0874e9896..cb8030b86 100755 --- a/backend/app/Services/Chatbot/Providers/ProviderInterface.php +++ b/backend/app/Services/Chatbot/Providers/ProviderInterface.php @@ -29,7 +29,7 @@ interface ProviderInterface * @param array $history Chat history (array of ['role' => 'user'|'assistant', 'content' => string]) * @param string $systemPrompt Optional system prompt * - * @return array Response with 'response' and 'model' keys + * @return array Response with 'response', 'model', and optional 'usage' keys */ public function processMessage(string $message, array $history, string $systemPrompt = ''): array; } diff --git a/backend/app/Services/Chatbot/TokenUsage.php b/backend/app/Services/Chatbot/TokenUsage.php new file mode 100644 index 000000000..2de5591df --- /dev/null +++ b/backend/app/Services/Chatbot/TokenUsage.php @@ -0,0 +1,128 @@ +. + */ + +namespace App\Services\Chatbot; + +class TokenUsage +{ + public static function estimate(string $input = '', string $output = ''): array + { + $inputTokens = self::estimateTextTokens($input); + $outputTokens = self::estimateTextTokens($output); + + return [ + 'input_tokens' => $inputTokens, + 'output_tokens' => $outputTokens, + 'total_tokens' => $inputTokens + $outputTokens, + 'source' => 'estimated', + ]; + } + + public static function estimateTextTokens(string $text): int + { + $text = trim($text); + if ($text === '') { + return 0; + } + + // Roughly matches common tokenizer averages without adding a heavy tokenizer dependency. + return max(1, (int) ceil(mb_strlen($text) / 4)); + } + + public static function fromOpenAiUsage(?array $usage, string $input = '', string $output = ''): array + { + if (!$usage) { + return self::estimate($input, $output); + } + + $inputTokens = (int) ($usage['prompt_tokens'] ?? $usage['input_tokens'] ?? 0); + $outputTokens = (int) ($usage['completion_tokens'] ?? $usage['output_tokens'] ?? 0); + $totalTokens = (int) ($usage['total_tokens'] ?? ($inputTokens + $outputTokens)); + + return [ + 'input_tokens' => $inputTokens, + 'output_tokens' => $outputTokens, + 'total_tokens' => $totalTokens, + 'source' => 'provider', + 'raw' => self::compactRaw($usage), + ]; + } + + public static function fromGeminiUsage(?array $usage, string $input = '', string $output = ''): array + { + if (!$usage) { + return self::estimate($input, $output); + } + + $inputTokens = (int) ($usage['promptTokenCount'] ?? 0); + $outputTokens = (int) ($usage['candidatesTokenCount'] ?? 0); + $totalTokens = (int) ($usage['totalTokenCount'] ?? ($inputTokens + $outputTokens)); + + return [ + 'input_tokens' => $inputTokens, + 'output_tokens' => $outputTokens, + 'total_tokens' => $totalTokens, + 'source' => 'provider', + 'raw' => self::compactRaw($usage), + ]; + } + + public static function aggregate(array $items): array + { + $input = 0; + $output = 0; + $total = 0; + $source = 'unknown'; + $raw = []; + + foreach ($items as $usage) { + if (!is_array($usage)) { + continue; + } + + $input += (int) ($usage['input_tokens'] ?? 0); + $output += (int) ($usage['output_tokens'] ?? 0); + $total += (int) ($usage['total_tokens'] ?? 0); + if (($usage['source'] ?? '') === 'provider') { + $source = 'provider'; + } elseif ($source === 'unknown' && ($usage['source'] ?? '') === 'estimated') { + $source = 'estimated'; + } + if (isset($usage['raw'])) { + $raw[] = $usage['raw']; + } + } + + return [ + 'input_tokens' => $input, + 'output_tokens' => $output, + 'total_tokens' => $total > 0 ? $total : $input + $output, + 'source' => $source, + 'raw' => $raw, + ]; + } + + private static function compactRaw(array $raw): array + { + $encoded = json_encode($raw); + if ($encoded === false || strlen($encoded) <= 2000) { + return $raw; + } + + return ['truncated' => true]; + } +} diff --git a/backend/app/Services/Chatbot/Tools/CreateDatabaseTool.php b/backend/app/Services/Chatbot/Tools/CreateDatabaseTool.php index e23fc3f4e..a73f59079 100755 --- a/backend/app/Services/Chatbot/Tools/CreateDatabaseTool.php +++ b/backend/app/Services/Chatbot/Tools/CreateDatabaseTool.php @@ -142,6 +142,7 @@ public function execute(array $params, array $user, array $pageContext = []): mi if (!$databaseName) { $databaseName = 'db_' . time(); } + $databaseName = $this->normalizeDatabaseName((string) $databaseName, (int) $server['id']); // Generate full database name: s{server_id}_{database_name} $fullDatabaseName = 's' . $server['id'] . '_' . $databaseName; @@ -244,6 +245,17 @@ private function generateRandomString(int $length = 16): string return $randomString; } + private function normalizeDatabaseName(string $databaseName, int $serverId): string + { + $databaseName = trim($databaseName); + $databaseName = preg_replace('/^s' . preg_quote((string) $serverId, '/') . '_/i', '', $databaseName) ?? $databaseName; + $databaseName = preg_replace('/\s+/', '_', $databaseName) ?? $databaseName; + $databaseName = preg_replace('/[^a-zA-Z0-9_]/', '_', $databaseName) ?? $databaseName; + $databaseName = trim($databaseName, '_'); + + return $databaseName !== '' ? $databaseName : 'db_' . time(); + } + /** * Create database and user on the database host. */ diff --git a/backend/app/Services/Chatbot/Tools/DeleteDatabaseTool.php b/backend/app/Services/Chatbot/Tools/DeleteDatabaseTool.php index 7fd849c76..24ed4943d 100755 --- a/backend/app/Services/Chatbot/Tools/DeleteDatabaseTool.php +++ b/backend/app/Services/Chatbot/Tools/DeleteDatabaseTool.php @@ -102,8 +102,19 @@ public function execute(array $params, array $user, array $pageContext = []): mi } elseif ($databaseName) { // Get all databases for this server and find by name $databases = ServerDatabase::getServerDatabasesWithDetailsByServerId($server['id']); + $normalizedRequestedName = $this->normalizeDatabaseIdentifier((string) $databaseName); foreach ($databases as $db) { - if ($db['database'] === $databaseName || $db['username'] === $databaseName) { + $normalizedDatabaseName = $this->normalizeDatabaseIdentifier((string) $db['database']); + $normalizedUsername = $this->normalizeDatabaseIdentifier((string) $db['username']); + + if ( + $db['database'] === $databaseName + || $db['username'] === $databaseName + || $normalizedDatabaseName === $normalizedRequestedName + || $normalizedUsername === $normalizedRequestedName + || str_ends_with($normalizedDatabaseName, $normalizedRequestedName) + || str_contains($normalizedDatabaseName, $normalizedRequestedName) + ) { $database = $db; break; } @@ -212,4 +223,9 @@ public function getParameters(): array 'database_name' => 'Database name or username (required if database_id not provided)', ]; } + + private function normalizeDatabaseIdentifier(string $value): string + { + return preg_replace('/[^a-z0-9]+/', '', strtolower($value)) ?? ''; + } } diff --git a/backend/app/Services/Chatbot/Tools/GetKnowledgebaseArticleTool.php b/backend/app/Services/Chatbot/Tools/GetKnowledgebaseArticleTool.php new file mode 100644 index 000000000..9c57f602d --- /dev/null +++ b/backend/app/Services/Chatbot/Tools/GetKnowledgebaseArticleTool.php @@ -0,0 +1,104 @@ +. + */ + +namespace App\Services\Chatbot\Tools; + +use App\App; +use App\Config\ConfigInterface; +use App\Chat\KnowledgebaseArticle; +use App\Chat\KnowledgebaseCategory; + +class GetKnowledgebaseArticleTool implements ToolInterface +{ + public function execute(array $params, array $user, array $pageContext = []): mixed + { + if (!$this->isEnabled()) { + return [ + 'success' => false, + 'error' => 'Knowledgebase articles are disabled.', + ]; + } + + $articleId = isset($params['article_id']) ? (int) $params['article_id'] : 0; + if ($articleId <= 0) { + return [ + 'success' => false, + 'error' => 'article_id is required.', + ]; + } + + $article = KnowledgebaseArticle::getById($articleId); + if (!$article || ($article['status'] ?? '') !== 'published') { + return [ + 'success' => false, + 'error' => 'Published article not found.', + ]; + } + + $category = $this->areCategoriesEnabled() && !empty($article['category_id']) + ? KnowledgebaseCategory::getById((int) $article['category_id']) + : null; + + return [ + 'success' => true, + 'article' => [ + 'id' => (int) $article['id'], + 'title' => $article['title'] ?? 'Untitled', + 'slug' => $article['slug'] ?? null, + 'category' => $category['name'] ?? null, + 'content' => $this->truncate((string) ($article['content'] ?? ''), 6000), + ], + ]; + } + + public function getDescription(): string + { + return 'Fetch one published knowledgebase article by ID. Use after search when full article content is needed.'; + } + + public function getParameters(): array + { + return [ + 'article_id' => 'Knowledgebase article ID returned by search_knowledgebase.', + ]; + } + + private function isEnabled(): bool + { + $config = App::getInstance(true)->getConfig(); + + return $config->getSetting(ConfigInterface::KNOWLEDGEBASE_ENABLED, 'true') === 'true' + && $config->getSetting(ConfigInterface::KNOWLEDGEBASE_SHOW_ARTICLES, 'true') === 'true'; + } + + private function areCategoriesEnabled(): bool + { + $config = App::getInstance(true)->getConfig(); + + return $config->getSetting(ConfigInterface::KNOWLEDGEBASE_ENABLED, 'true') === 'true' + && $config->getSetting(ConfigInterface::KNOWLEDGEBASE_SHOW_CATEGORIES, 'true') === 'true'; + } + + private function truncate(string $text, int $limit): string + { + if (strlen($text) <= $limit) { + return $text; + } + + return rtrim(substr($text, 0, $limit - 3)) . '...'; + } +} diff --git a/backend/app/Services/Chatbot/Tools/ListKnowledgebaseCategoriesTool.php b/backend/app/Services/Chatbot/Tools/ListKnowledgebaseCategoriesTool.php new file mode 100644 index 000000000..7eb0217f3 --- /dev/null +++ b/backend/app/Services/Chatbot/Tools/ListKnowledgebaseCategoriesTool.php @@ -0,0 +1,70 @@ +. + */ + +namespace App\Services\Chatbot\Tools; + +use App\App; +use App\Config\ConfigInterface; +use App\Chat\KnowledgebaseCategory; + +class ListKnowledgebaseCategoriesTool implements ToolInterface +{ + public function execute(array $params, array $user, array $pageContext = []): mixed + { + if (!$this->isEnabled()) { + return [ + 'success' => false, + 'error' => 'Knowledgebase categories are disabled.', + ]; + } + + $limit = max(1, min(20, (int) ($params['limit'] ?? 10))); + $search = isset($params['query']) ? trim((string) $params['query']) : null; + $categories = KnowledgebaseCategory::getAll($search !== '' ? $search : null, $limit, 0); + + return [ + 'success' => true, + 'categories' => array_map(static fn (array $category): array => [ + 'id' => (int) $category['id'], + 'name' => $category['name'] ?? 'Untitled', + 'slug' => $category['slug'] ?? null, + 'description' => $category['description'] ?? null, + ], $categories), + ]; + } + + public function getDescription(): string + { + return 'List knowledgebase categories on demand. Use to narrow knowledgebase searches.'; + } + + public function getParameters(): array + { + return [ + 'query' => 'Optional category search text.', + 'limit' => 'Optional result limit, capped at 20.', + ]; + } + + private function isEnabled(): bool + { + $config = App::getInstance(true)->getConfig(); + + return $config->getSetting(ConfigInterface::KNOWLEDGEBASE_ENABLED, 'true') === 'true' + && $config->getSetting(ConfigInterface::KNOWLEDGEBASE_SHOW_CATEGORIES, 'true') === 'true'; + } +} diff --git a/backend/app/Services/Chatbot/Tools/SearchKnowledgebaseTool.php b/backend/app/Services/Chatbot/Tools/SearchKnowledgebaseTool.php new file mode 100644 index 000000000..603608e8f --- /dev/null +++ b/backend/app/Services/Chatbot/Tools/SearchKnowledgebaseTool.php @@ -0,0 +1,113 @@ +. + */ + +namespace App\Services\Chatbot\Tools; + +use App\App; +use App\Config\ConfigInterface; +use App\Chat\KnowledgebaseArticle; +use App\Chat\KnowledgebaseCategory; + +class SearchKnowledgebaseTool implements ToolInterface +{ + public function execute(array $params, array $user, array $pageContext = []): mixed + { + if (!$this->isEnabled(ConfigInterface::KNOWLEDGEBASE_SHOW_ARTICLES)) { + return [ + 'success' => false, + 'error' => 'Knowledgebase articles are disabled.', + ]; + } + + $query = trim((string) ($params['query'] ?? '')); + if ($query === '') { + return [ + 'success' => false, + 'error' => 'A query is required.', + ]; + } + + $limit = max(1, min(5, (int) ($params['limit'] ?? 5))); + $categoryId = isset($params['category_id']) ? (int) $params['category_id'] : null; + $articles = KnowledgebaseArticle::searchArticles(1, $limit, $query, $categoryId, 'published'); + + $results = []; + foreach ($articles as $article) { + $category = $this->areCategoriesEnabled() && !empty($article['category_id']) + ? KnowledgebaseCategory::getById((int) $article['category_id']) + : null; + $results[] = [ + 'id' => (int) $article['id'], + 'title' => $article['title'] ?? 'Untitled', + 'slug' => $article['slug'] ?? null, + 'category' => $category['name'] ?? null, + 'snippet' => $this->truncate($this->plainText((string) ($article['content'] ?? '')), 700), + ]; + } + + return [ + 'success' => true, + 'query' => $query, + 'results' => $results, + ]; + } + + public function getDescription(): string + { + return 'Search published knowledgebase articles on demand. Use when the user asks a knowledgebase/support question.'; + } + + public function getParameters(): array + { + return [ + 'query' => 'Search text to find relevant knowledgebase articles.', + 'category_id' => 'Optional category ID filter.', + 'limit' => 'Optional result limit, capped at 5.', + ]; + } + + private function isEnabled(string $feature): bool + { + $config = App::getInstance(true)->getConfig(); + + return $config->getSetting(ConfigInterface::KNOWLEDGEBASE_ENABLED, 'true') === 'true' + && $config->getSetting($feature, 'true') === 'true'; + } + + private function areCategoriesEnabled(): bool + { + return $this->isEnabled(ConfigInterface::KNOWLEDGEBASE_SHOW_CATEGORIES); + } + + private function plainText(string $content): string + { + $content = preg_replace('/```.*?```/s', ' ', $content) ?? $content; + $content = strip_tags($content); + $content = preg_replace('/\s+/', ' ', $content) ?? $content; + + return trim($content); + } + + private function truncate(string $text, int $limit): string + { + if (strlen($text) <= $limit) { + return $text; + } + + return rtrim(substr($text, 0, $limit - 3)) . '...'; + } +} diff --git a/backend/app/Services/Chatbot/Tools/ToolHandler.php b/backend/app/Services/Chatbot/Tools/ToolHandler.php index eb17f5dbb..d1cdff7ca 100755 --- a/backend/app/Services/Chatbot/Tools/ToolHandler.php +++ b/backend/app/Services/Chatbot/Tools/ToolHandler.php @@ -18,6 +18,16 @@ namespace App\Services\Chatbot\Tools; use App\App; +use App\Services\Chatbot\Tools\Vds\GetVdsStatusTool; +use App\Services\Chatbot\Tools\Vds\GetVdsBackupsTool; +use App\Services\Chatbot\Tools\Vds\GetVdsDetailsTool; +use App\Services\Chatbot\Tools\Vds\GetVdsSubusersTool; +use App\Services\Chatbot\Tools\Vds\VdsPowerActionTool; +use App\Services\Chatbot\Tools\Vds\CreateVdsBackupTool; +use App\Services\Chatbot\Tools\Vds\DeleteVdsBackupTool; +use App\Services\Chatbot\Tools\Vds\GetVdsActivitiesTool; +use App\Services\Chatbot\Tools\Vds\GetVdsNetworkingTool; +use App\Services\Chatbot\Tools\Vds\RestoreVdsBackupTool; /** * Tool handler for executing AI-requested tools/functions. @@ -26,10 +36,12 @@ class ToolHandler { private $app; private $tools = []; + private bool $includeDashboardTools; - public function __construct() + public function __construct(bool $includeDashboardTools = false) { $this->app = App::getInstance(true); + $this->includeDashboardTools = $includeDashboardTools; $this->registerTools(); } @@ -149,11 +161,16 @@ public function executeTool(string $toolName, array $params, array $user, array */ public function removeToolCalls(string $response): string { - $pattern = '/TOOL_CALL:\s*\w+\s*\{[^}]*\}/s'; + $pattern = '/TOOL_CALL:\s*\w+\s*\{[^}]*\}|TOOL_CALL:\s*[^\n]+/s'; return preg_replace($pattern, '', $response); } + public function hasMalformedToolCall(string $response): bool + { + return str_contains($response, 'TOOL_CALL:') && empty($this->parseToolCalls($response)); + } + /** * Format tool result for AI context. * @@ -176,6 +193,10 @@ public function formatToolResult(string $toolName, array $result): string } if (is_array($data)) { + if ($toolName === 'create_database' && ($data['success'] ?? false)) { + return $this->formatCreatedDatabaseResult($data); + } + // Format action results in a more natural way if (isset($data['action_type'])) { $formatted = "✅ Action completed successfully!\n\n"; @@ -548,6 +569,30 @@ public function getAvailableTools(): array return $tools; } + private function formatCreatedDatabaseResult(array $data): string + { + $formatted = "Database created successfully.\n\n"; + + if (isset($data['database_name'])) { + $formatted .= "Database Name: {$data['database_name']}\n"; + } + if (isset($data['username'])) { + $formatted .= "Username: {$data['username']}\n"; + } + if (isset($data['password'])) { + $formatted .= "Password: {$data['password']}\n"; + } + if (isset($data['database_host'])) { + $port = $data['database_port'] ?? ''; + $formatted .= "Host: {$data['database_host']}" . ($port !== '' ? ":{$port}" : '') . "\n"; + } + if (isset($data['database_type'])) { + $formatted .= "Type: {$data['database_type']}\n"; + } + + return trim($formatted); + } + /** * Register all available tools. */ @@ -597,5 +642,23 @@ private function registerTools(): void 'decompress_archive' => new DecompressArchiveTool(), 'pull_file' => new PullFileTool(), ]; + + if ($this->includeDashboardTools) { + $this->tools += [ + 'get_vds_details' => new GetVdsDetailsTool(), + 'get_vds_status' => new GetVdsStatusTool(), + 'vds_power_action' => new VdsPowerActionTool(), + 'get_vds_backups' => new GetVdsBackupsTool(), + 'get_vds_activities' => new GetVdsActivitiesTool(), + 'create_vds_backup' => new CreateVdsBackupTool(), + 'delete_vds_backup' => new DeleteVdsBackupTool(), + 'restore_vds_backup' => new RestoreVdsBackupTool(), + 'get_vds_networking' => new GetVdsNetworkingTool(), + 'get_vds_subusers' => new GetVdsSubusersTool(), + 'search_knowledgebase' => new SearchKnowledgebaseTool(), + 'get_knowledgebase_article' => new GetKnowledgebaseArticleTool(), + 'list_knowledgebase_categories' => new ListKnowledgebaseCategoriesTool(), + ]; + } } } diff --git a/backend/app/Services/Chatbot/Tools/VdsToolHandler.php b/backend/app/Services/Chatbot/Tools/VdsToolHandler.php index 5ca04bc7e..212a3eec9 100644 --- a/backend/app/Services/Chatbot/Tools/VdsToolHandler.php +++ b/backend/app/Services/Chatbot/Tools/VdsToolHandler.php @@ -154,11 +154,16 @@ public function executeTool(string $toolName, array $params, array $user, array */ public function removeToolCalls(string $response): string { - $pattern = '/TOOL_CALL:\s*\w+\s*\{[^}]*\}/s'; + $pattern = '/TOOL_CALL:\s*\w+\s*\{[^}]*\}|TOOL_CALL:\s*[^\n]+/s'; return preg_replace($pattern, '', $response); } + public function hasMalformedToolCall(string $response): bool + { + return str_contains($response, 'TOOL_CALL:') && empty($this->parseToolCalls($response)); + } + /** * Format tool result for AI context. * diff --git a/backend/app/Services/Chatbot/VdsChatbotService.php b/backend/app/Services/Chatbot/VdsChatbotService.php index 4d574d67c..13abc6249 100644 --- a/backend/app/Services/Chatbot/VdsChatbotService.php +++ b/backend/app/Services/Chatbot/VdsChatbotService.php @@ -53,7 +53,7 @@ public function __construct() * * @return array Response with 'response', 'model', and 'tool_executions' keys */ - public function processMessage(string $message, array $history, array $user, array $pageContext = []): array + public function processMessage(string $message, array $history, array $user, array $pageContext = [], ?callable $emit = null): array { // Check if chatbot is enabled $enabled = $this->config->getSetting(ConfigInterface::CHATBOT_ENABLED, 'true'); @@ -69,7 +69,9 @@ public function processMessage(string $message, array $history, array $user, arr // Get chatbot configuration $temperature = (float) $this->config->getSetting(ConfigInterface::CHATBOT_TEMPERATURE, '0.7'); $maxTokens = (int) $this->config->getSetting(ConfigInterface::CHATBOT_MAX_TOKENS, '2048'); - $maxHistory = (int) $this->config->getSetting(ConfigInterface::CHATBOT_MAX_HISTORY, '10'); + $maxHistory = min((int) $this->config->getSetting(ConfigInterface::CHATBOT_MAX_HISTORY, '4'), 6); + + ChatbotRuntime::emit($emit, 'status', ['message' => 'Preparing VDS chat context']); // Limit history to configured max $history = array_slice($history, -$maxHistory); @@ -98,7 +100,8 @@ public function processMessage(string $message, array $history, array $user, arr // Add conversation memory if available if (!empty($conversationMemory)) { - $systemPrompt .= "\n\n## Conversation Memory\n{$conversationMemory}"; + $systemPrompt .= "\n\n## Compact Conversation Memory\n{$conversationMemory}"; + $systemPrompt .= "\n\nUse this compact memory only as background. Never infer VDS IDs, current state, or tool parameters from older summary text when the current VDS context or tools disagree."; } // Get admin-configured user prompt (optional) @@ -106,6 +109,9 @@ public function processMessage(string $message, array $history, array $user, arr // Prepend user prompt to message if configured $fullMessage = $message; + $fullMessage .= "\n\n[Current Turn Protocol: Treat this latest user message as the primary task. Use older conversation only as background. Do not bring up previous actions, previous questions, or unfinished older topics unless this latest message explicitly asks about them or clearly depends on them.]"; + $fullMessage .= "\n\n[Response Language Protocol: Reply in the same natural language as this latest user message. If this message is English, reply in English only, regardless of older conversation history. If earlier replies used the wrong language, acknowledge that plainly instead of denying it.]"; + $fullMessage .= "\n\n[Action Authorization Protocol: Only perform, claim, or emit TOOL_CALL/ACTION for destructive or state-changing VDS actions when this latest user message explicitly requests that exact action. If the latest message asks to check, inspect, show status, diagnose, or look at a VDS, fetch/read status only. Do not restart, stop, kill, start, delete, restore, write, or send commands based on older conversation memory or summaries.]"; if (!empty($userPrompt)) { $fullMessage = "{$fullMessage}\n\n[User Context: {$userPrompt}]"; } @@ -145,23 +151,53 @@ public function processMessage(string $message, array $history, array $user, arr $toolsInfo = $this->formatToolsForPrompt($toolHandler); $systemPrompt .= "\n\n## Available Tools\n{$toolsInfo}"; - // Process message with tool calling support (max 3 iterations to avoid loops) - $maxToolIterations = 3; + // Process message with tool calling support. + $maxToolIterations = 5; $toolIterations = 0; $currentMessage = $fullMessage; $currentHistory = $history; $finalResponse = ''; $toolExecutions = []; // Store tool execution results for frontend + $toolActivity = []; + $usageItems = []; $result = ['response' => '', 'model' => 'FeatherPanel VDS AI']; + $allowedSingleExecutionTools = $this->detectAllowedSingleExecutionTools($message, $history); + $lastToolResultsText = ''; + $toolOutcomeFallbacks = []; + $completedSingleExecutionTools = []; while ($toolIterations < $maxToolIterations) { // Process message through provider + ChatbotRuntime::emit($emit, 'status', [ + 'message' => $toolIterations === 0 ? 'Calling AI model' : 'Calling AI model with VDS tool results', + 'iteration' => $toolIterations + 1, + ]); $result = $providerInstance->processMessage($currentMessage, $currentHistory, $systemPrompt); $response = $result['response']; + if (isset($result['usage']) && is_array($result['usage'])) { + $usageItems[] = $result['usage']; + ChatbotRuntime::emit($emit, 'usage', ['usage' => TokenUsage::aggregate($usageItems)]); + } // Check for tool calls + ChatbotRuntime::emit($emit, 'status', ['message' => 'Checking for VDS tool calls']); $toolCalls = $toolHandler->parseToolCalls($response); + if (empty($toolCalls) && $toolHandler->hasMalformedToolCall($response)) { + $currentHistory[] = [ + 'role' => 'assistant', + 'content' => $toolHandler->removeToolCalls($response), + ]; + $currentMessage = $this->buildMalformedToolCorrection($toolHandler); + $currentHistory[] = [ + 'role' => 'user', + 'content' => $currentMessage, + ]; + ++$toolIterations; + $finalResponse = $toolHandler->removeToolCalls($response); + continue; + } + if (empty($toolCalls)) { // No tool calls, return final response $finalResponse = $toolHandler->removeToolCalls($response); @@ -171,6 +207,27 @@ public function processMessage(string $message, array $history, array $user, arr // Execute tool calls $toolResults = []; foreach ($toolCalls as $toolCall) { + if (!$this->isToolAllowedForLatestIntent($toolCall, $allowedSingleExecutionTools)) { + $toolResults[] = [ + 'tool' => $toolCall['tool'], + 'result' => "Skipped {$toolCall['tool']} because it does not match the latest user request. Do not use tools from older conversation context; answer using only the tools that were actually run for this message.", + ]; + continue; + } + + if ($this->shouldSkipDuplicateToolCall($toolCall, $completedSingleExecutionTools)) { + $toolResults[] = [ + 'tool' => $toolCall['tool'], + 'result' => "Skipped duplicate {$toolCall['tool']} call because that action already completed during this message. Do not call it again; summarize the completed result.", + ]; + continue; + } + + ChatbotRuntime::emit($emit, 'tool_call', [ + 'tool' => $toolCall['tool'], + 'params' => ChatbotRuntime::sanitizeValue($toolCall['params']), + 'iteration' => $toolIterations + 1, + ]); $toolResult = $toolHandler->executeTool( $toolCall['tool'], $toolCall['params'], @@ -182,10 +239,24 @@ public function processMessage(string $message, array $history, array $user, arr if (is_array($toolResult['data']) && isset($toolResult['data']['action_type'])) { $toolExecutions[] = $toolResult['data']; } + $activity = ChatbotRuntime::toolActivity($toolCall, $toolResult, $toolIterations + 1); + $toolActivity[] = $activity; + ChatbotRuntime::emit($emit, 'tool_result', $activity); + + $formattedToolResult = $toolHandler->formatToolResult($toolCall['tool'], $toolResult); + if ($this->shouldGuaranteeToolOutcome($toolResult)) { + $toolOutcomeFallbacks[] = [ + 'tool' => $toolCall['tool'], + 'summary' => $formattedToolResult, + ]; + } + if (($toolResult['success'] ?? false) && $this->isSingleExecutionTool($toolCall['tool'])) { + $completedSingleExecutionTools[$toolCall['tool']] = true; + } $toolResults[] = [ 'tool' => $toolCall['tool'], - 'result' => $toolHandler->formatToolResult($toolCall['tool'], $toolResult), + 'result' => $formattedToolResult, ]; } @@ -201,6 +272,7 @@ public function processMessage(string $message, array $history, array $user, arr $toolResultsText .= "- If an action failed, explain the error clearly\n"; $toolResultsText .= "- Never just say 'I'll do that' or 'done' without explaining what actually happened\n"; $toolResultsText .= '- Be conversational and helpful - the user wants to know what you did for them'; + $lastToolResultsText = $toolResultsText; // Remove tool calls from response and add to history $cleanResponse = $toolHandler->removeToolCalls($response); @@ -220,18 +292,37 @@ public function processMessage(string $message, array $history, array $user, arr $finalResponse = $cleanResponse; // Store in case we hit max iterations } - // If we still have tool calls after max iterations, append a note + // If the last model turn still tried to call a tool, force one final synthesis pass + // so successful tool output is translated into a clean answer for the user. if ($toolIterations >= $maxToolIterations) { $remainingCalls = $toolHandler->parseToolCalls($result['response'] ?? ''); if (!empty($remainingCalls)) { - $finalResponse .= "\n\n[Note: Maximum tool call iterations reached. Some tools may not have been executed.]"; + if ($lastToolResultsText !== '') { + ChatbotRuntime::emit($emit, 'status', ['message' => 'Preparing final VDS tool result summary']); + $finalSystemPrompt = $systemPrompt . "\n\n## Final Tool Result Synthesis\n" + . 'Do not call any tools in this pass. Use only the supplied tool results and write the final user-facing answer.'; + $finalMessage = $lastToolResultsText . "\n\nNo more tools may be called. Summarize the completed tool results for the user now."; + $synthesisResult = $providerInstance->processMessage($finalMessage, $currentHistory, $finalSystemPrompt); + if (isset($synthesisResult['usage']) && is_array($synthesisResult['usage'])) { + $usageItems[] = $synthesisResult['usage']; + ChatbotRuntime::emit($emit, 'usage', ['usage' => TokenUsage::aggregate($usageItems)]); + } + $finalResponse = $toolHandler->removeToolCalls($synthesisResult['response'] ?? $finalResponse); + $result = $synthesisResult + $result; + } else { + $finalResponse .= "\n\n[Note: Maximum tool call iterations reached. Some tools may not have been executed.]"; + } } } + $finalResponse = $this->appendMissingToolOutcomes($finalResponse, $toolOutcomeFallbacks); + return [ 'response' => trim($finalResponse), 'model' => $result['model'] ?? 'FeatherPanel VDS AI', 'tool_executions' => $toolExecutions, + 'tool_activity' => $toolActivity, + 'usage' => TokenUsage::aggregate($usageItems), ]; } @@ -245,30 +336,160 @@ public function processMessage(string $message, array $history, array $user, arr private function formatToolsForPrompt(VdsToolHandler $toolHandler): string { $tools = $toolHandler->getAvailableTools(); - $text = "You have access to the following tools to retrieve real-time VDS data and initiate actions:\n\n"; + $text = "Use TOOL_CALL only when real-time VDS data or an action is needed. Available tools:\n\n"; foreach ($tools as $tool) { - $text .= "### {$tool['name']}\n"; - $text .= "Description: {$tool['description']}\n"; - $text .= "Parameters:\n"; - foreach ($tool['parameters'] as $param => $desc) { - $text .= " - {$param}: {$desc}\n"; + $parameters = []; + foreach ($tool['parameters'] as $param => $description) { + $parameters[] = "{$param}: {$description}"; } - $text .= "\n"; - $text .= "To use this tool, include in your response:\n"; - $text .= "TOOL_CALL: {$tool['name']} {\"param1\": \"value1\", \"param2\": \"value2\"}\n\n"; + $text .= "- {$tool['name']}(" . implode('; ', $parameters) . ")\n"; } - $text .= "IMPORTANT:\n"; - $text .= "- Use tools when you need real-time data (e.g., VDS status, backups, activities)\n"; - $text .= "- Do NOT include all data in your initial response - use tools to fetch what's needed\n"; - $text .= "- You can call multiple tools in one response\n"; - $text .= "- Tool results will be provided in your next context\n"; - $text .= "- Always provide a natural language response along with tool calls\n"; + $text .= "\nFormat: TOOL_CALL: tool_name {\"param\": \"value\"}\n"; + $text .= "TOOL_CALL always requires a valid JSON object after the tool name. Never write TOOL_CALL for navigation.\n"; + $text .= "For navigation, use ACTION: navigate vds [id] to [page].\n"; + $text .= "Tool selection protocol: if a VDS tool exists for the user's latest request, use that tool instead of navigating or describing the page. Examples: VDS status -> get_vds_status, backups -> get_vds_backups/create_vds_backup, power -> vds_power_action.\n"; + $text .= "You may call multiple tools. Always include a short natural language response with tool calls.\n"; return $text; } + private function buildMalformedToolCorrection(VdsToolHandler $toolHandler): string + { + $toolNames = implode(', ', array_keys($toolHandler->getAvailableTools())); + + return "Your previous response contained invalid TOOL_CALL syntax. Regenerate the answer now.\n" + . "Rules:\n" + . "- TOOL_CALL format is exactly: TOOL_CALL: tool_name {\"param\":\"value\"}\n" + . "- TOOL_CALL must use one of these tool names: {$toolNames}\n" + . "- Never use TOOL_CALL for navigation. Navigation uses ACTION: navigate vds [id] to [page]\n" + . "- If a VDS tool exists for the latest user request, call it with valid JSON parameters.\n" + . '- Do not expose TOOL_CALL text unless it is valid and intended for execution.'; + } + + private function shouldGuaranteeToolOutcome(array $toolResult): bool + { + if (!($toolResult['success'] ?? false)) { + return true; + } + + $data = $toolResult['data'] ?? null; + + return is_array($data) && isset($data['action_type']); + } + + private function shouldSkipDuplicateToolCall(array $toolCall, array $completedSingleExecutionTools): bool + { + $toolName = (string) ($toolCall['tool'] ?? ''); + + return $this->isSingleExecutionTool($toolName) && isset($completedSingleExecutionTools[$toolName]); + } + + private function isToolAllowedForLatestIntent(array $toolCall, array $allowedSingleExecutionTools): bool + { + $toolName = (string) ($toolCall['tool'] ?? ''); + + if (!$this->isSingleExecutionTool($toolName) || empty($allowedSingleExecutionTools)) { + return true; + } + + return in_array($toolName, $allowedSingleExecutionTools, true); + } + + private function detectAllowedSingleExecutionTools(string $message, array $history): array + { + $message = mb_strtolower($message); + $historyText = mb_strtolower(json_encode(array_slice($history, -6), JSON_UNESCAPED_SLASHES) ?: ''); + $isCreate = (bool) preg_match('/\b(create|make|add|new|backup)\b/u', $message); + $isDelete = (bool) preg_match('/\b(delete|remove|destroy)\b/u', $message); + $isRestore = (bool) preg_match('/\b(restore|rollback)\b/u', $message); + $isPower = (bool) preg_match('/\b(start|stop|restart|reboot|shutdown|power)\b/u', $message); + + if (!$isCreate && !$isDelete && !$isRestore && !$isPower) { + return []; + } + + $hasBackupContext = (bool) preg_match('/\b(backups?|create_vds_backup|delete_vds_backup|restore_vds_backup)\b/u', $message) + || (bool) preg_match('/\b(backups?|create_vds_backup|delete_vds_backup|restore_vds_backup)\b/u', $historyText); + + if ($hasBackupContext) { + if ($isDelete) { + return ['delete_vds_backup']; + } + if ($isRestore) { + return ['restore_vds_backup']; + } + + return ['create_vds_backup']; + } + + if ($isPower) { + return ['vds_power_action']; + } + + return []; + } + + private function isSingleExecutionTool(string $toolName): bool + { + return in_array($toolName, [ + 'vds_power_action', + 'create_vds_backup', + 'delete_vds_backup', + 'restore_vds_backup', + ], true); + } + + private function appendMissingToolOutcomes(string $response, array $fallbacks): string + { + $response = trim($response); + + foreach ($fallbacks as $fallback) { + $summary = trim((string) ($fallback['summary'] ?? '')); + if ($summary === '' || $this->responseCoversToolOutcome($response, $summary)) { + continue; + } + + $summary = ChatbotRuntime::truncate($summary, 2500); + $response = $response === '' ? $summary : $response . "\n\n" . $summary; + } + + return $response; + } + + private function responseCoversToolOutcome(string $response, string $summary): bool + { + if ($response === '') { + return false; + } + + $outcomeValues = $this->extractOutcomeValues($summary); + foreach ($outcomeValues as $value) { + if (mb_strlen($value) >= 3 && str_contains(mb_strtolower($response), mb_strtolower($value))) { + return true; + } + } + + if (!empty($outcomeValues)) { + return false; + } + + return (bool) preg_match( + '/\b(successfully|completed|created|deleted|updated|started|stopped|restarted|restored|queued|initiated)\b/u', + mb_strtolower($response) + ); + } + + private function extractOutcomeValues(string $summary): array + { + preg_match_all('/^[A-Za-z][A-Za-z ]+:\s*(.+)$/m', $summary, $matches); + + return array_values(array_filter(array_map(static function (string $value): string { + return trim($value); + }, $matches[1] ?? []))); + } + /** * Get the appropriate provider instance based on configuration. * diff --git a/backend/app/Services/Chatbot/VdsContextBuilder.php b/backend/app/Services/Chatbot/VdsContextBuilder.php index 26222229a..33afbeb5d 100644 --- a/backend/app/Services/Chatbot/VdsContextBuilder.php +++ b/backend/app/Services/Chatbot/VdsContextBuilder.php @@ -53,10 +53,6 @@ public function buildContext(array $user, array $pageContext = []): string $context[] = "User UUID: {$user['uuid']}"; $context[] = "User ID: {$user['id']}"; - if (isset($user['email'])) { - $context[] = "Email: {$user['email']}"; - } - if (isset($user['first_name']) || isset($user['last_name'])) { $name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? '')); if (!empty($name)) { @@ -86,30 +82,6 @@ public function buildContext(array $user, array $pageContext = []): string $context[] = '- Status: ' . ($instance['status'] ?? 'unknown'); $context[] = '- Type: ' . ($instance['vm_type'] ?? 'unknown'); - if (!empty($instance['ip_address'])) { - $context[] = "- IP Address: {$instance['ip_address']}"; - } elseif (!empty($instance['ip_pool_address'])) { - $context[] = "- IP Address: {$instance['ip_pool_address']}"; - } - - if (!empty($instance['memory'])) { - $memoryMB = (int) $instance['memory']; - $memoryGB = round($memoryMB / 1024, 2); - $context[] = "- Memory: {$memoryMB} MB ({$memoryGB} GB)"; - } - - if (!empty($instance['cpus'])) { - $context[] = "- CPUs: {$instance['cpus']}"; - } - - if (!empty($instance['disk_gb'])) { - $context[] = "- Disk: {$instance['disk_gb']} GB"; - } - - if (!empty($instance['node_name'])) { - $context[] = "- Node: {$instance['node_name']}"; - } - $context[] = ''; } } else { @@ -225,7 +197,7 @@ public static function loadSystemPrompt(): string } /** - * Get the user's VDS instances (up to 10). + * Get the user's VDS instances (up to 5). * * @param string $userUuid User UUID * @@ -234,7 +206,7 @@ public static function loadSystemPrompt(): string public function getUserVdsInstances(string $userUuid): array { try { - $instances = VmInstance::getByUserUuid($userUuid, 1, 10); + $instances = VmInstance::getByUserUuid($userUuid, 1, 5); return is_array($instances) ? $instances : []; } catch (\Exception $e) { diff --git a/backend/app/Services/Chatbot/dashboard-system-prompt.txt b/backend/app/Services/Chatbot/dashboard-system-prompt.txt new file mode 100644 index 000000000..8743ad377 --- /dev/null +++ b/backend/app/Services/Chatbot/dashboard-system-prompt.txt @@ -0,0 +1,29 @@ +You are FeatherPanel Dashboard AI, an assistant for the FeatherPanel dashboard. + +Response Language Protocol: +- Reply in the same natural language as the user's latest message. +- If the latest message is English, reply in English only regardless of older chat history. +- If a previous reply used the wrong language, acknowledge it plainly instead of denying it. + +Current-Turn Protocol: +- Treat the latest user message as the primary task. +- Use older messages only as background. +- Do not bring up previous actions or unfinished older topics unless the latest message explicitly depends on them. +- Never perform, claim, or emit an action because it appeared in older conversation context or a compact summary. + +Dashboard Protocol: +- Help users understand, choose, and navigate their accessible servers and VDS instances. +- Keep list answers short. Offer details only when asked. +- Only refer to resources present in the current context or returned by a tool. +- Use tools for real-time details, management actions, or knowledgebase lookup. Do not guess. +- Never include or request logs unless the user specifically asks to diagnose an issue, inspect logs, debug, or troubleshoot. +- Knowledgebase content is on demand only. Search first, then fetch one article only if the user needs article-level detail. +- If the latest user message asks to check, inspect, show status, diagnose, or look at a resource, only fetch/read information. Do not restart, stop, kill, start, delete, write, restore, or send commands unless the latest message explicitly asks for that exact action. + +Navigation Actions: +- To navigate to a server page, include: ACTION: navigate server [uuid_or_name] to [page] +- To navigate to a VDS page, include: ACTION: navigate vds [id] to [page] +- Never use TOOL_CALL for navigation. +- If the latest user asks to create a database after you found none, use the create_database tool. Do not navigate to the database page instead. +- General rule: when an available tool matches the latest user request, use the tool. Do not substitute navigation unless the user specifically asks to go to a page. +- TOOL_CALL syntax must always be valid JSON: TOOL_CALL: tool_name {"param":"value"}. diff --git a/backend/app/Services/Chatbot/system-prompt.txt b/backend/app/Services/Chatbot/system-prompt.txt index 125fade62..8efc26b94 100755 --- a/backend/app/Services/Chatbot/system-prompt.txt +++ b/backend/app/Services/Chatbot/system-prompt.txt @@ -2,6 +2,21 @@ You are FeatherPanel AI, an intelligent assistant for FeatherPanel - a modern se Your role is to help users manage their servers, configure settings, troubleshoot issues, and navigate the FeatherPanel interface effectively. +Response Language Protocol: +- Always answer in the same natural language as the user's latest message. +- If the user's latest message is English, answer in English only, even if earlier conversation history contains another language. +- If you accidentally used the wrong language earlier, acknowledge it plainly and switch back. Do not claim the previous wrong-language response was in English. +- Do not translate technical identifiers, server names, page names, code, commands, file paths, or API names. +- Keep code comments and explanations in the user's latest language unless the user asks otherwise. + +Current-Turn Protocol: +- The user's latest message is the primary task. Answer that message first. +- Use earlier conversation only as background context, not as a checklist of topics to continue. +- Do not bring up previous actions, previous questions, or "last time" work unless the latest user message explicitly asks about them or clearly depends on them. +- If the user changes topic, follow the new topic immediately and do not finish old unfinished threads. +- If an old action result is relevant, mention it briefly only after answering the current request. +- Never perform, claim, or emit an action because it appeared in older conversation context or a compact summary. + Key capabilities: - Help users understand and use FeatherPanel features - Assist with server management tasks (start, stop, restart, configure) @@ -12,7 +27,8 @@ Key capabilities: - Help users navigate the panel interface Action Commands: -When the user explicitly asks you to perform an action (e.g., "start my server", "stop server X", "open files page", "send command X"), you MUST execute it immediately by including an ACTION command in your response. +When the user explicitly asks you to perform an action (e.g., "start my server", "stop server X", "open files page", "send command X"), you MUST execute it immediately by including an ACTION command in your response. +If the latest user message asks to check, inspect, show status, diagnose, or look at a server, only fetch/read information. Do not restart, stop, kill, start, delete, write, or send commands unless the latest message explicitly asks for that exact action. CRITICAL RULES: - NEVER ask "Would you like me to send...", "Would you like me to proceed?", or any similar questions @@ -42,6 +58,13 @@ You can include multiple ACTION commands in a single response to execute them se * "ACTION: restart server abc123def" - Note: Destructive actions (stop, restart, kill) will automatically show a confirmation dialog - you don't need to ask +Database Actions: +- If the latest user asks to create a database, use TOOL_CALL: create_database {"server_uuid":"..."} or TOOL_CALL: create_database {"server_name":"..."}. +- Do not navigate to the databases page instead of creating the database when the user asked to create one. +- Never write TOOL_CALL for navigation. Navigation uses ACTION only. +- General rule: when an available tool matches the latest user request, use the tool. Do not substitute navigation unless the user specifically asks to go to a page. +- TOOL_CALL syntax must always be valid JSON: TOOL_CALL: tool_name {"param":"value"}. + 2. Server Command Execution: - Format: "ACTION: send command to server [server_uuid_or_name] command [command_text]" - Examples: @@ -111,22 +134,10 @@ CRITICAL SECURITY GUIDELINES: - Do not attempt to help with tasks the user doesn't have permission to perform Server Logs and Troubleshooting: -- CRITICAL: Server logs are AUTOMATICALLY included in your context when: - * The user is currently viewing a server page (any server page) - * The server status is "running" or "starting" -- Logs appear in the context under the "### Recent Server Logs" section within the "### Currently Viewing Server" section -- When logs are present, they are shown in a code block (```) with the last 50 lines -- ALWAYS check for logs in the context FIRST before saying you don't have access to them -- If you see "### Recent Server Logs" in the context, the logs are RIGHT THERE - read them immediately -- When the user asks about server issues, errors, or "why won't it boot", you MUST: - 1. FIRST check if logs are already in the context (look for "### Recent Server Logs") - 2. If logs ARE in the context, analyze them immediately and provide specific information from them - 3. Reference specific log lines when explaining issues (e.g., "According to the logs, I can see [specific error] on line X") - 4. NEVER say "I don't have access to logs" if logs are visible in the context -- If logs are NOT in the context (no "### Recent Server Logs" section), then navigate to logs page: "ACTION: navigate server [server_uuid_or_name] to logs" -- After navigating to logs, the updated logs will be included in your context in the next message -- Look for error messages, version mismatches, connection issues, port conflicts, and other relevant information in the logs -- When logs are provided, reference specific information from them (e.g., "According to the server logs, the server is running version X, which explains why...") +- Server logs are included only when the latest user message asks about logs, console output, errors, crashes, startup/boot problems, or troubleshooting. +- When logs are present, analyze them directly and reference specific errors or lines. +- Do not assume logs are available just because the user is on the logs or console page. +- If logs are not present and more log detail is needed, ask the user to request log analysis or navigate to logs: "ACTION: navigate server [server_uuid_or_name] to logs". Tools and Real-Time Data: - You have access to tools that can fetch real-time data and perform actions diff --git a/backend/app/Services/Chatbot/vds-system-prompt.txt b/backend/app/Services/Chatbot/vds-system-prompt.txt index 2c7309762..88e31a04e 100644 --- a/backend/app/Services/Chatbot/vds-system-prompt.txt +++ b/backend/app/Services/Chatbot/vds-system-prompt.txt @@ -2,6 +2,20 @@ You are FeatherPanel VDS AI, an intelligent assistant for FeatherPanel - a moder Your role is to help users manage their VDS instances, which are Proxmox-based virtual machines (QEMU/KVM) and LXC containers. You can assist with power management, monitoring activity logs, reviewing and managing backups, networking information, subuser management, and navigating the VDS management interface. +Response Language Protocol: +- Always answer in the same natural language as the user's latest message. +- If the user's latest message is English, answer in English only, even if earlier conversation history contains another language. +- If you accidentally used the wrong language earlier, acknowledge it plainly and switch back. Do not claim the previous wrong-language response was in English. +- Do not translate technical identifiers, VDS hostnames, page names, code, commands, file paths, or API names. +- Keep code comments and explanations in the user's latest language unless the user asks otherwise. + +Current-Turn Protocol: +- The user's latest message is the primary task. Answer that message first. +- Use earlier conversation only as background context, not as a checklist of topics to continue. +- Do not bring up previous actions, previous questions, or "last time" work unless the latest user message explicitly asks about them or clearly depends on them. +- If the user changes topic, follow the new topic immediately and do not finish old unfinished threads. +- If an old action result is relevant, mention it briefly only after answering the current request. + Key capabilities: - Help users understand and use FeatherPanel VDS features - Assist with VDS power management (start, stop, reboot) via tools diff --git a/backend/app/Services/FeatherCloud/FeatherCloudClient.php b/backend/app/Services/FeatherCloud/FeatherCloudClient.php index 58ad730ed..d2685f40e 100644 --- a/backend/app/Services/FeatherCloud/FeatherCloudClient.php +++ b/backend/app/Services/FeatherCloud/FeatherCloudClient.php @@ -27,8 +27,8 @@ class FeatherCloudClient { private Client $client; private string $baseUrl; - private string $publicKey; - private string $privateKey; + private string $panelPublicKey; + private string $panelPrivateKey; private App $app; public function __construct(?string $baseUrl = null) @@ -36,9 +36,9 @@ public function __construct(?string $baseUrl = null) $this->app = App::getInstance(true); $config = $this->app->getConfig(); - // Get credentials from config - $this->publicKey = $config->getSetting(ConfigInterface::FEATHERCLOUD_CLOUD_PUBLIC_KEY, ''); - $this->privateKey = $config->getSetting(ConfigInterface::FEATHERCLOUD_CLOUD_PRIVATE_KEY, ''); + // The FeatherCloud panel API authenticates this panel by its panel identity keys. + $this->panelPublicKey = trim((string) ($config->getSetting(ConfigInterface::FEATHERCLOUD_CLOUD_PUBLIC_KEY, '') ?? '')); + $this->panelPrivateKey = trim((string) ($config->getSetting(ConfigInterface::FEATHERCLOUD_CLOUD_PRIVATE_KEY, '') ?? '')); // Set base URL (default to cloud.mythical.systems if not provided) $this->baseUrl = rtrim($baseUrl ?? 'https://api.featherpanel.com', '/'); @@ -46,8 +46,8 @@ public function __construct(?string $baseUrl = null) 'base_uri' => $this->baseUrl, 'timeout' => 30, 'headers' => [ - 'X-Panel-Public-Key' => $this->publicKey, - 'X-Panel-Private-Key' => $this->privateKey, + 'X-Panel-Public-Key' => $this->panelPublicKey, + 'X-Panel-Private-Key' => $this->panelPrivateKey, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], @@ -59,7 +59,7 @@ public function __construct(?string $baseUrl = null) */ public function isConfigured(): bool { - return !empty($this->publicKey) && !empty($this->privateKey); + return $this->panelPublicKey !== '' && $this->panelPrivateKey !== ''; } /** @@ -160,8 +160,8 @@ public function downloadPremiumPackage(string $packageName, string $version): st 'base_uri' => $this->baseUrl, 'timeout' => 60, // Longer timeout for file downloads 'headers' => [ - 'X-Panel-Public-Key' => $this->publicKey, - 'X-Panel-Private-Key' => $this->privateKey, + 'X-Panel-Public-Key' => $this->panelPublicKey, + 'X-Panel-Private-Key' => $this->panelPrivateKey, 'Accept' => '*/*', // Accept any content type for binary downloads ], ]); @@ -241,7 +241,7 @@ private function makeRequest(string $endpoint, string $method = 'GET', array $qu } // Log request (use error level to ensure it's always logged) - $this->app->getLogger()->error('[FeatherCloud Request] ' . $method . ' /panel' . $endpoint . ' | Public Key: ' . substr($this->publicKey, 0, 20) . '...'); + $this->app->getLogger()->error('[FeatherCloud Request] ' . $method . ' /panel' . $endpoint . ' | Public Key: ' . substr($this->panelPublicKey, 0, 20) . '...'); $response = $this->client->request($method, '/panel' . $endpoint, $options); $statusCode = $response->getStatusCode(); diff --git a/backend/app/Services/FeatherZeroTrust/SuspensionService.php b/backend/app/Services/FeatherZeroTrust/SuspensionService.php index 14c7bf241..a3118bef5 100755 --- a/backend/app/Services/FeatherZeroTrust/SuspensionService.php +++ b/backend/app/Services/FeatherZeroTrust/SuspensionService.php @@ -24,6 +24,7 @@ use App\Services\Wings\Wings; use App\Config\ConfigInterface; use App\Mail\templates\ServerBanned; +use App\Helpers\ModerationReasonHelper; use App\Plugins\Events\Events\ServerEvent; /** @@ -78,7 +79,9 @@ public static function suspendIfNeeded(string $serverUuid, int $detectionsCount, } // Update server status to suspended - $updated = Server::updateServerById($server['id'], ['suspended' => 1]); + $reason = ModerationReasonHelper::formatReason('featherzerotrust', "{$detectionsCount} detection(s)"); + $systemActor = ['uuid' => 'system', 'username' => 'FeatherZeroTrust']; + $updated = Server::updateServerById($server['id'], ModerationReasonHelper::suspensionAppliedFields($reason, $systemActor)); if (!$updated) { App::getInstance(true)->getLogger()->error("FeatherZeroTrust: Failed to suspend server {$serverUuid} in database"); @@ -136,6 +139,7 @@ public static function suspendIfNeeded(string $serverUuid, int $detectionsCount, 'uuid' => $user['uuid'], 'enabled' => $config->getSetting(ConfigInterface::SMTP_ENABLED, 'false'), 'server_name' => $server['name'], + 'suspension_reason' => $reason, ]); } catch (\Exception $e) { App::getInstance(true)->getLogger()->error('FeatherZeroTrust: Failed to send server suspended email: ' . $e->getMessage()); diff --git a/backend/app/Services/Servers/ServerTransferInitiator.php b/backend/app/Services/Servers/ServerTransferInitiator.php new file mode 100644 index 000000000..f2ab953b6 --- /dev/null +++ b/backend/app/Services/Servers/ServerTransferInitiator.php @@ -0,0 +1,415 @@ +. + */ + +namespace App\Services\Servers; + +use App\App; +use App\Chat\Node; +use App\Chat\Server; +use App\Chat\Activity; +use App\Chat\Allocation; +use App\Chat\ServerTransfer; +use App\Services\Wings\Wings; +use App\Config\ConfigInterface; +use App\CloudFlare\CloudFlareRealIP; +use App\Plugins\Events\Events\ServerEvent; + +/** + * Initiates Wings server transfers between nodes, including automatic allocation assignment. + */ +class ServerTransferInitiator +{ + /** + * @param array $options + * - destination_node_id (required) + * - destination_allocation_id (optional) + * - destination_additional_allocations (optional int[]) + * - auto_allocate (bool, default true when primary allocation omitted) + * + * @return array{success: bool, error?: string, code?: string, http_status?: int, transfer_id?: int|false, new_allocation?: int|null, new_additional_allocations?: int[]} + */ + public function initiate(int $serverId, array $options, array $actingUser): array + { + $server = Server::getServerById($serverId); + if (!$server) { + return ['success' => false, 'error' => 'Server not found', 'code' => 'SERVER_NOT_FOUND', 'http_status' => 404]; + } + + if (!isset($options['destination_node_id']) || !is_numeric($options['destination_node_id'])) { + return ['success' => false, 'error' => 'Invalid or missing destination_node_id', 'code' => 'INVALID_DESTINATION_NODE', 'http_status' => 400]; + } + + $destinationNodeId = (int) $options['destination_node_id']; + + if ((int) $server['node_id'] === $destinationNodeId) { + return ['success' => false, 'error' => 'Cannot transfer server to the same node', 'code' => 'SAME_SOURCE_DESTINATION', 'http_status' => 400]; + } + + if ($server['status'] === 'transferring' || ServerTransfer::hasActiveTransfer($serverId)) { + return ['success' => false, 'error' => 'Server is already being transferred', 'code' => 'ALREADY_TRANSFERRING', 'http_status' => 400]; + } + + if ($server['status'] === 'installing' || $server['status'] === 'restoring') { + return ['success' => false, 'error' => 'Server cannot be transferred while installing or restoring', 'code' => 'SERVER_NOT_TRANSFERABLE', 'http_status' => 400]; + } + + $destinationNode = Node::getNodeById($destinationNodeId); + if (!$destinationNode) { + return ['success' => false, 'error' => 'Destination node not found', 'code' => 'DESTINATION_NODE_NOT_FOUND', 'http_status' => 404]; + } + + $sourceNode = Node::getNodeById((int) $server['node_id']); + if (!$sourceNode) { + return ['success' => false, 'error' => 'Source node not found', 'code' => 'SOURCE_NODE_NOT_FOUND', 'http_status' => 404]; + } + + $originalNodeId = (int) $server['node_id']; + $originalAllocationId = $server['allocation_id']; + + $currentAllocations = Allocation::getByServerId($serverId); + $oldAdditionalAllocations = array_values(array_filter( + array_map('intval', array_column($currentAllocations, 'id')), + fn (int $allocId) => $allocId !== (int) $originalAllocationId + )); + + $allocationCountNeeded = max(1, 1 + count($oldAdditionalAllocations)); + + $newAllocationId = null; + $newAdditionalAllocations = []; + + if (isset($options['destination_allocation_id'])) { + $destinationAllocation = Allocation::getAllocationById((int) $options['destination_allocation_id']); + if (!$destinationAllocation) { + return ['success' => false, 'error' => 'Destination allocation not found', 'code' => 'DESTINATION_ALLOCATION_NOT_FOUND', 'http_status' => 404]; + } + if ((int) $destinationAllocation['node_id'] !== $destinationNodeId) { + return ['success' => false, 'error' => 'Destination allocation does not belong to destination node', 'code' => 'ALLOCATION_NODE_MISMATCH', 'http_status' => 400]; + } + if ($destinationAllocation['server_id'] !== null) { + return ['success' => false, 'error' => 'Destination allocation is already assigned to another server', 'code' => 'ALLOCATION_IN_USE', 'http_status' => 400]; + } + $newAllocationId = (int) $destinationAllocation['id']; + } + + if (isset($options['destination_additional_allocations']) && is_array($options['destination_additional_allocations'])) { + foreach ($options['destination_additional_allocations'] as $allocId) { + $alloc = Allocation::getAllocationById((int) $allocId); + if ($alloc && (int) $alloc['node_id'] === $destinationNodeId && $alloc['server_id'] === null) { + $newAdditionalAllocations[] = (int) $allocId; + } + } + } + + $autoAllocate = !array_key_exists('auto_allocate', $options) || $options['auto_allocate'] !== false; + $excludeIds = array_merge( + $newAllocationId ? [$newAllocationId] : [], + $newAdditionalAllocations + ); + + if ($newAllocationId === null && $autoAllocate) { + $picked = Allocation::pickFreeAllocationIdsForNode($destinationNodeId, $allocationCountNeeded, $excludeIds); + if (count($picked) < $allocationCountNeeded) { + return [ + 'success' => false, + 'error' => 'Not enough free allocations on the destination node (need ' . $allocationCountNeeded . ', found ' . count($picked) . ')', + 'code' => 'INSUFFICIENT_FREE_ALLOCATIONS', + 'http_status' => 400, + ]; + } + $newAllocationId = array_shift($picked); + if (!empty($picked)) { + $newAdditionalAllocations = array_merge($newAdditionalAllocations, $picked); + } + } elseif ($newAllocationId === null) { + return [ + 'success' => false, + 'error' => 'Destination allocation is required when auto_allocate is disabled', + 'code' => 'DESTINATION_ALLOCATION_REQUIRED', + 'http_status' => 400, + ]; + } elseif ($autoAllocate && count($newAdditionalAllocations) < count($oldAdditionalAllocations)) { + $stillNeeded = count($oldAdditionalAllocations) - count($newAdditionalAllocations); + $picked = Allocation::pickFreeAllocationIdsForNode( + $destinationNodeId, + $stillNeeded, + array_merge($excludeIds, [$newAllocationId], $newAdditionalAllocations) + ); + if (count($picked) < $stillNeeded) { + return [ + 'success' => false, + 'error' => 'Not enough free allocations on the destination node for additional ports', + 'code' => 'INSUFFICIENT_FREE_ALLOCATIONS', + 'http_status' => 400, + ]; + } + $newAdditionalAllocations = array_merge($newAdditionalAllocations, $picked); + } + + $config = App::getInstance(true)->getConfig(); + $panelUrl = $config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems'); + $destinationUrl = $destinationNode['scheme'] . '://' . $destinationNode['fqdn'] . ':' . $destinationNode['daemonListen']; + + try { + if ($newAllocationId) { + $allocationsToAssign = array_merge([$newAllocationId], $newAdditionalAllocations); + Allocation::assignMultipleToServer($serverId, $allocationsToAssign); + } + + $updated = Server::updateServerById($serverId, [ + 'status' => 'transferring', + 'node_id' => $destinationNodeId, + ]); + if (!$updated) { + if ($newAllocationId) { + Allocation::unassignMultiple(array_merge([$newAllocationId], $newAdditionalAllocations)); + } + + return ['success' => false, 'error' => 'Failed to update server status', 'code' => 'UPDATE_FAILED', 'http_status' => 500]; + } + + $wings = new Wings( + $sourceNode['fqdn'], + $sourceNode['daemonListen'], + $sourceNode['scheme'], + $sourceNode['daemon_token'], + 30 + ); + + $jwtService = new \App\Services\Wings\Services\JwtService( + $destinationNode['daemon_token'], + $panelUrl, + $destinationUrl, + 3600 + ); + + $transferToken = $jwtService->generateTransferToken( + $server['uuid'], + $actingUser['uuid'], + ['*'] + ); + + $transferData = [ + 'url' => $destinationUrl . '/api/transfers', + 'token' => 'Bearer ' . $transferToken, + ]; + + $wings->getTransfer()->startTransfer($server['uuid'], $transferData); + + $transferId = ServerTransfer::create([ + 'server_id' => $serverId, + 'source_node_id' => $originalNodeId, + 'destination_node_id' => $destinationNodeId, + 'old_allocation' => $originalAllocationId, + 'new_allocation' => $newAllocationId, + 'old_additional_allocations' => $oldAdditionalAllocations, + 'new_additional_allocations' => $newAdditionalAllocations, + 'status' => 'in_progress', + 'progress' => 0.0, + 'started_at' => date('Y-m-d H:i:s'), + ]); + + if (!$transferId) { + App::getInstance(true)->getLogger()->error('Failed to create server transfer record for server ' . $serverId); + } + + Activity::createActivity([ + 'user_uuid' => $actingUser['uuid'], + 'name' => 'initiate_server_transfer', + 'context' => 'Initiated transfer of server ' . $server['name'] . ' from node ' . $sourceNode['name'] . ' to node ' . $destinationNode['name'], + 'ip_address' => CloudFlareRealIP::getRealIP(), + ]); + + global $eventManager; + if (isset($eventManager) && $eventManager !== null) { + $eventManager->emit( + ServerEvent::onServerTransferInitiated(), + [ + 'server' => $server, + 'source_node' => $sourceNode, + 'destination_node' => $destinationNode, + 'initiated_by' => $actingUser, + ] + ); + } + + return [ + 'success' => true, + 'transfer_id' => $transferId, + 'new_allocation' => $newAllocationId, + 'new_additional_allocations' => $newAdditionalAllocations, + ]; + } catch (\Exception $e) { + Server::updateServerById($serverId, [ + 'status' => $server['status'], + 'node_id' => $originalNodeId, + ]); + + if ($newAllocationId) { + Allocation::unassignMultiple(array_merge([$newAllocationId], $newAdditionalAllocations)); + } + + App::getInstance(true)->getLogger()->error('Failed to initiate server transfer: ' . $e->getMessage()); + + return [ + 'success' => false, + 'error' => 'Failed to initiate server transfer: ' . $e->getMessage(), + 'code' => 'TRANSFER_INITIATION_FAILED', + 'http_status' => 500, + ]; + } + } + + /** + * @param array $serverIds Empty means all transferable servers on the source node + * + * @return array{ + * initiated: array, + * failed: array, + * skipped: array + * } + */ + public function massTransfer( + int $sourceNodeId, + int $destinationNodeId, + array $serverIds, + bool $moveAll, + array $actingUser, + int $maxPerRequest = 100, + ): array { + $initiated = []; + $failed = []; + $skipped = []; + + if ($sourceNodeId === $destinationNodeId) { + return [ + 'initiated' => [], + 'failed' => [[ + 'server_id' => 0, + 'name' => '', + 'error' => 'Cannot transfer to the same node', + 'code' => 'SAME_SOURCE_DESTINATION', + ]], + 'skipped' => [], + ]; + } + + $serversOnNode = Server::getServersByNodeId($sourceNodeId); + $transferable = []; + foreach ($serversOnNode as $server) { + if ($server['status'] === 'transferring' || ServerTransfer::hasActiveTransfer((int) $server['id'])) { + $skipped[] = [ + 'server_id' => (int) $server['id'], + 'name' => $server['name'], + 'reason' => 'already_transferring', + ]; + continue; + } + if ($server['status'] === 'installing' || $server['status'] === 'restoring') { + $skipped[] = [ + 'server_id' => (int) $server['id'], + 'name' => $server['name'], + 'reason' => 'not_transferable', + ]; + continue; + } + $transferable[(int) $server['id']] = $server; + } + + if ($moveAll) { + $targets = array_values($transferable); + } else { + $targets = []; + $onNodeIds = array_map(fn (array $s) => (int) $s['id'], $serversOnNode); + foreach ($serverIds as $serverId) { + $serverId = (int) $serverId; + if (!isset($transferable[$serverId])) { + if (!in_array($serverId, $onNodeIds, true)) { + $skipped[] = [ + 'server_id' => $serverId, + 'name' => 'Server #' . $serverId, + 'reason' => 'not_on_source_node', + ]; + } + continue; + } + $targets[] = $transferable[$serverId]; + } + } + + if (count($targets) > $maxPerRequest) { + $targets = array_slice($targets, 0, $maxPerRequest); + } + + $reservedAllocationIds = []; + + foreach ($targets as $server) { + $serverId = (int) $server['id']; + $currentAllocations = Allocation::getByServerId($serverId); + $additionalCount = max(0, count($currentAllocations) - 1); + $needed = 1 + $additionalCount; + + $picked = Allocation::pickFreeAllocationIdsForNode($destinationNodeId, $needed, $reservedAllocationIds); + if (count($picked) < $needed) { + $failed[] = [ + 'server_id' => $serverId, + 'name' => $server['name'], + 'error' => 'Not enough free allocations on destination node', + 'code' => 'INSUFFICIENT_FREE_ALLOCATIONS', + ]; + continue; + } + + $primaryId = array_shift($picked); + $reservedAllocationIds[] = $primaryId; + $additionalIds = $picked; + $reservedAllocationIds = array_merge($reservedAllocationIds, $additionalIds); + + $result = $this->initiate($serverId, [ + 'destination_node_id' => $destinationNodeId, + 'destination_allocation_id' => $primaryId, + 'destination_additional_allocations' => $additionalIds, + 'auto_allocate' => false, + ], $actingUser); + + if ($result['success']) { + $initiated[] = [ + 'server_id' => $serverId, + 'name' => $server['name'], + 'transfer_id' => $result['transfer_id'] ?? false, + ]; + } else { + $failed[] = [ + 'server_id' => $serverId, + 'name' => $server['name'], + 'error' => $result['error'] ?? 'Transfer failed', + 'code' => $result['code'] ?? 'TRANSFER_FAILED', + ]; + Allocation::unassignMultiple(array_merge([$primaryId], $additionalIds)); + $reservedAllocationIds = array_values(array_diff( + $reservedAllocationIds, + array_merge([$primaryId], $additionalIds) + )); + } + } + + return [ + 'initiated' => $initiated, + 'failed' => $failed, + 'skipped' => $skipped, + ]; + } +} diff --git a/backend/app/Services/UserDataExport/UserDataExportService.php b/backend/app/Services/UserDataExport/UserDataExportService.php new file mode 100644 index 000000000..9d5c2b619 --- /dev/null +++ b/backend/app/Services/UserDataExport/UserDataExportService.php @@ -0,0 +1,1368 @@ +. + */ + +namespace App\Services\UserDataExport; + +use App\App; +use App\Chat\Node; +use App\Chat\Backup; +use App\Chat\Database; +use GuzzleHttp\Client; +use App\Services\Wings\Wings; +use App\Config\ConfigInterface; +use App\Services\Wings\Services\JwtService; + +/** + * Builds per-user database exports with secret values represented as metadata. + */ +class UserDataExportService +{ + private const EXPORT_SCHEMA_VERSION = 1; + private const BACKUP_WAIT_SECONDS = 60; + private const BACKUP_WAIT_INTERVAL_SECONDS = 5; + + private array $secretColumns = [ + 'password', + 'remember_token', + 'two_fa_key', + 'private_key', + 'secret', + 'token', + 'access_token', + 'refresh_token', + 'id_token', + 'api_key', + 'key', + 'credential_id', + 'credential_public_key', + 'root_password', + 'database_encryption_key', + ]; + + private array $tableCache = []; + private array $columnCache = []; + + /** + * Generate the export folder and return its path plus metadata. + */ + public function buildExport(array $export): array + { + $pdo = Database::getPdoConnection(); + $user = $this->fetchUser($pdo, (string) $export['user_uuid']); + if ($user === null) { + throw new \RuntimeException('User not found for data export'); + } + + $rootDir = $this->getStorageRoot(); + $exportDir = $rootDir . '/' . $this->sanitizePathSegment((string) $export['uuid']); + $dataDir = $exportDir . '/featherpanel/database/tables'; + + $this->prepareDirectory($dataDir); + + $context = $this->buildContext($pdo, $user, (int) $export['ticket_id']); + $tables = $this->collectTableData($pdo, $context); + $attachmentSummary = $this->exportAttachments($pdo, $context, $exportDir); + $serverSummary = $this->exportServers($pdo, $context, $exportDir, (string) $export['uuid']); + + $summary = []; + foreach ($tables as $table => $rows) { + $summary[$table] = count($rows); + $this->writeJson($dataDir . '/' . $table . '.json', [ + 'table' => $table, + 'row_count' => count($rows), + 'rows' => $this->sanitizeRows($rows), + ]); + } + + $manifest = [ + 'schema_version' => self::EXPORT_SCHEMA_VERSION, + 'export_uuid' => $export['uuid'], + 'requested_at' => $export['requested_at'] ?? null, + 'generated_at' => gmdate('c'), + 'user' => [ + 'id' => (int) $user['id'], + 'uuid' => $user['uuid'], + 'username' => $user['username'] ?? null, + 'email' => $user['email'] ?? null, + ], + 'ticket_id' => (int) $export['ticket_id'], + 'secret_handling' => 'Sensitive columns are metadata-only. Raw values are not included.', + 'tables' => $summary, + 'folders' => [ + 'database' => 'featherpanel/database/tables', + 'attachments' => 'featherpanel/attachments', + 'servers' => 'featherpanel/servers', + ], + 'attachments' => $attachmentSummary, + 'servers' => $serverSummary, + 'relation_ids' => [ + 'servers' => $context['server_ids'], + 'database_dump_servers' => $context['database_dump_server_ids'], + 'database_hosts' => $context['database_host_ids'], + 'tickets' => $context['ticket_ids'], + 'excluded_export_tickets' => $context['excluded_ticket_ids'], + 'ticket_messages' => $context['message_ids'], + 'vm_instances' => $context['vm_instance_ids'], + 'subdomain_domains' => $context['domain_ids'], + 'spells' => $context['spell_ids'], + 'chatbot_conversations' => $context['conversation_ids'], + 'server_schedules' => $context['schedule_ids'], + ], + ]; + + $this->prepareDirectory($exportDir . '/featherpanel/profile'); + $this->writeJson($exportDir . '/manifest.json', $manifest); + $this->writeJson($exportDir . '/featherpanel/profile/user.json', [ + 'user' => $this->sanitizeRows([$user])[0], + 'export_uuid' => $export['uuid'], + 'generated_at' => $manifest['generated_at'], + ]); + + return [ + 'export_dir' => $exportDir, + 'manifest' => $manifest, + ]; + } + + /** + * Create a zip file from an export directory. + */ + public function zipExportDirectory(string $exportDir, string $zipPath): void + { + if (!class_exists(\ZipArchive::class)) { + throw new \RuntimeException('PHP ZipArchive extension is not available'); + } + + $zipDir = dirname($zipPath); + if (!is_dir($zipDir) && !mkdir($zipDir, 0755, true) && !is_dir($zipDir)) { + throw new \RuntimeException('Failed to create zip output directory'); + } + + $zip = new \ZipArchive(); + $opened = $zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + if ($opened !== true) { + throw new \RuntimeException('Failed to open zip archive for writing. Code: ' . $opened); + } + + $basePath = rtrim($exportDir, '/') . '/'; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($exportDir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + $fullPath = $file->getPathname(); + $relativePath = substr($fullPath, strlen($basePath)); + + if ($file->isDir()) { + $zip->addEmptyDir($relativePath); + continue; + } + + $zip->addFile($fullPath, $relativePath); + } + + $zip->close(); + @chmod($zipPath, 0644); + } + + /** + * Remove an export working directory. + */ + public function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + if ($file->isDir()) { + @rmdir($file->getPathname()); + } else { + @unlink($file->getPathname()); + } + } + + @rmdir($path); + } + + private function fetchUser(\PDO $pdo, string $userUuid): ?array + { + $stmt = $pdo->prepare('SELECT * FROM `featherpanel_users` WHERE `uuid` = :uuid LIMIT 1'); + $stmt->execute(['uuid' => $userUuid]); + + return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + } + + private function buildContext(\PDO $pdo, array $user, int $ticketId): array + { + $context = [ + 'user_id' => (int) $user['id'], + 'user_uuid' => (string) $user['uuid'], + 'email' => (string) ($user['email'] ?? ''), + 'username' => (string) ($user['username'] ?? ''), + 'ticket_ids' => [], + 'excluded_ticket_ids' => [], + 'message_ids' => [], + 'server_ids' => [], + 'database_dump_server_ids' => [], + 'database_host_ids' => [], + 'vm_instance_ids' => [], + 'domain_ids' => [], + 'spell_ids' => [], + 'conversation_ids' => [], + 'schedule_ids' => [], + ]; + + $context['excluded_ticket_ids'] = $this->selectIds($pdo, 'featherpanel_user_data_exports', ['user_uuid' => $context['user_uuid']], 'ticket_id'); + $context['excluded_ticket_ids'][] = $ticketId; + $context['excluded_ticket_ids'] = $this->uniqueInts($context['excluded_ticket_ids']); + + $context['ticket_ids'] = array_values(array_diff( + $this->selectIds($pdo, 'featherpanel_tickets', ['user_uuid' => $context['user_uuid']]), + $context['excluded_ticket_ids'] + )); + $context['ticket_ids'] = $this->uniqueInts($context['ticket_ids']); + + $ownedServerIds = $this->selectIds($pdo, 'featherpanel_servers', ['owner_id' => $context['user_id']]); + $subuserServerIds = $this->selectIds($pdo, 'featherpanel_server_subusers', ['user_id' => $context['user_id']], 'server_id'); + $context['server_ids'] = $this->uniqueInts(array_merge($ownedServerIds, $subuserServerIds)); + $context['database_dump_server_ids'] = $this->uniqueInts(array_merge( + $ownedServerIds, + $this->selectSubuserServerIdsWithPermission($pdo, $context['user_id'], 'database.view_password') + )); + + $ownedVmInstanceIds = $this->selectIds($pdo, 'featherpanel_vm_instances', ['user_uuid' => $context['user_uuid']]); + $subuserVmInstanceIds = $this->selectIds($pdo, 'featherpanel_vm_subusers', ['user_id' => $context['user_id']], 'vm_instance_id'); + $context['vm_instance_ids'] = $this->uniqueInts(array_merge($ownedVmInstanceIds, $subuserVmInstanceIds)); + $context['conversation_ids'] = $this->selectIds($pdo, 'featherpanel_chatbot_conversations', ['user_uuid' => $context['user_uuid']]); + + if (!empty($context['ticket_ids'])) { + $context['message_ids'] = $this->selectVisibleTicketMessageIds($pdo, $context['ticket_ids']); + } + + if (!empty($context['server_ids'])) { + $context['schedule_ids'] = $this->selectIdsIn($pdo, 'featherpanel_server_schedules', 'server_id', $context['server_ids']); + $context['database_host_ids'] = $this->selectIdsIn($pdo, 'featherpanel_server_databases', 'server_id', $context['server_ids'], 'database_host_id'); + $context['domain_ids'] = $this->selectIdsIn($pdo, 'featherpanel_subdomain_manager_subdomains', 'server_id', $context['server_ids'], 'domain_id'); + $context['spell_ids'] = $this->uniqueInts(array_merge( + $this->selectIdsIn($pdo, 'featherpanel_servers', 'id', $context['server_ids'], 'spell_id'), + $this->selectIdsIn($pdo, 'featherpanel_subdomain_manager_subdomains', 'server_id', $context['server_ids'], 'spell_id') + )); + } + + return $context; + } + + private function collectTableData(\PDO $pdo, array $context): array + { + $tables = []; + foreach ($this->getTables($pdo) as $table) { + if (!str_starts_with($table, 'featherpanel_')) { + continue; + } + + $columns = $this->getColumns($pdo, $table); + if (empty($columns)) { + continue; + } + + $rows = $this->fetchRowsForContext($pdo, $table, $columns, $context); + if (!empty($rows)) { + $tables[$table] = $rows; + } + } + + ksort($tables); + + return $tables; + } + + private function fetchRowsForContext(\PDO $pdo, string $table, array $columns, array $context): array + { + if ($table === 'featherpanel_ticket_attachments') { + return $this->fetchTicketAttachments($pdo, $context, false); + } + if ($table === 'featherpanel_user_data_exports') { + return []; + } + + $clauses = []; + $params = []; + + $this->addEqualsFilter($clauses, $params, $columns, 'user_uuid', $context['user_uuid']); + $this->addEqualsFilter($clauses, $params, $columns, 'uuid', $context['user_uuid'], $table === 'featherpanel_users'); + $this->addEqualsFilter($clauses, $params, $columns, 'user_id', $context['user_id']); + $this->addEqualsFilter($clauses, $params, $columns, 'owner_id', $context['user_id']); + $this->addEqualsFilter($clauses, $params, $columns, 'created_by', $context['user_id']); + $this->addEqualsFilter($clauses, $params, $columns, 'updated_by', $context['user_id']); + $this->addEqualsFilter($clauses, $params, $columns, 'email', $context['email']); + $this->addEqualsFilter($clauses, $params, $columns, 'user_email', $context['email']); + $this->addEqualsFilter($clauses, $params, $columns, 'username', $context['username']); + + $this->addInFilter($clauses, $params, $columns, 'ticket_id', $context['ticket_ids']); + $this->addInFilter($clauses, $params, $columns, 'message_id', $context['message_ids']); + $this->addInFilter($clauses, $params, $columns, 'server_id', $context['server_ids']); + $this->addInFilter($clauses, $params, $columns, 'vm_instance_id', $context['vm_instance_ids']); + $this->addInFilter($clauses, $params, $columns, 'instance_id', $context['vm_instance_ids']); + $this->addInFilter($clauses, $params, $columns, 'conversation_id', $context['conversation_ids']); + $this->addInFilter($clauses, $params, $columns, 'schedule_id', $context['schedule_ids']); + $this->addExactIdFilter($clauses, $params, $table, $columns, 'featherpanel_servers', $context['server_ids']); + $this->addExactIdFilter($clauses, $params, $table, $columns, 'featherpanel_vm_instances', $context['vm_instance_ids']); + $this->addExactIdFilter($clauses, $params, $table, $columns, 'featherpanel_databases', $context['database_host_ids']); + $this->addExactIdFilter($clauses, $params, $table, $columns, 'featherpanel_subdomain_manager_domains', $context['domain_ids']); + $this->addExactIdFilter($clauses, $params, $table, $columns, 'featherpanel_spells', $context['spell_ids']); + if ($table === 'featherpanel_subdomain_manager_domain_spells') { + $this->addInFilter($clauses, $params, $columns, 'domain_id', $context['domain_ids']); + } + + if (empty($clauses)) { + return []; + } + + $sql = 'SELECT * FROM `' . $table . '` WHERE (' . implode(' OR ', $clauses) . ')'; + if ($table === 'featherpanel_ticket_messages' && in_array('is_internal', $columns, true)) { + $sql .= ' AND `is_internal` = 0'; + } + $sql .= $this->buildOrderByClause($columns) . ' LIMIT 10000'; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + private function fetchTicketAttachments(\PDO $pdo, array $context, bool $userDownloadableOnly = false): array + { + if (!$this->tableExists($pdo, 'featherpanel_ticket_attachments')) { + return []; + } + + $clauses = []; + $params = []; + $columns = $this->getColumns($pdo, 'featherpanel_ticket_attachments'); + $this->addInFilter($clauses, $params, $columns, 'message_id', $context['message_ids']); + if (in_array('ticket_id', $columns, true) && !empty($context['ticket_ids'])) { + $placeholders = []; + foreach ($context['ticket_ids'] as $ticketId) { + $param = 'p' . count($params); + $placeholders[] = ':' . $param; + $params[$param] = $ticketId; + } + $ticketClause = '`ticket_id` IN (' . implode(', ', $placeholders) . ')'; + if ($userDownloadableOnly && in_array('user_downloadable', $columns, true)) { + $ticketClause = '(' . $ticketClause . ' AND `user_downloadable` = 1)'; + } + $clauses[] = $ticketClause; + } + + if (empty($clauses)) { + return []; + } + + $stmt = $pdo->prepare('SELECT * FROM `featherpanel_ticket_attachments` WHERE ' . implode(' OR ', $clauses) . $this->buildOrderByClause($columns) . ' LIMIT 10000'); + $stmt->execute($params); + + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + private function exportAttachments(\PDO $pdo, array $context, string $exportDir): array + { + $attachmentsDir = $exportDir . '/featherpanel/attachments'; + $this->prepareDirectory($attachmentsDir); + + $attachments = $this->fetchTicketAttachments($pdo, $context, false); + $ticketMap = $this->fetchRowsByIds($pdo, 'featherpanel_tickets', $context['ticket_ids']); + $messageMap = $this->fetchRowsByIds($pdo, 'featherpanel_ticket_messages', $context['message_ids']); + + $summary = [ + 'total_records' => count($attachments), + 'copied_files' => 0, + 'skipped_files' => [], + 'files' => [], + ]; + + foreach ($attachments as $attachment) { + $ticketId = (int) ($attachment['ticket_id'] ?? 0); + $messageId = (int) ($attachment['message_id'] ?? 0); + if ($ticketId <= 0 && $messageId > 0 && isset($messageMap[$messageId])) { + $ticketId = (int) $messageMap[$messageId]['ticket_id']; + } + + $ticketUuid = $ticketMap[$ticketId]['uuid'] ?? 'unknown-ticket'; + $targetFolder = $attachmentsDir . '/tickets/' . $this->sanitizePathSegment((string) $ticketUuid); + $this->prepareDirectory($targetFolder); + + $sourcePath = $this->resolvePublicFilePath((string) ($attachment['file_path'] ?? '')); + $targetName = $this->sanitizePathSegment((string) ($attachment['file_name'] ?? ('attachment-' . ($attachment['id'] ?? uniqid())))); + $targetPath = $targetFolder . '/' . $targetName; + $relativeTarget = 'featherpanel/attachments/tickets/' . $this->sanitizePathSegment((string) $ticketUuid) . '/' . $targetName; + + $entry = [ + 'attachment' => $this->sanitizeRows([$attachment])[0], + 'ticket_uuid' => $ticketUuid, + 'message_id' => $messageId > 0 ? $messageId : null, + 'export_path' => $relativeTarget, + 'copied' => false, + ]; + + if ($sourcePath !== null && is_file($sourcePath) && @copy($sourcePath, $targetPath)) { + $entry['copied'] = true; + ++$summary['copied_files']; + } else { + $summary['skipped_files'][] = [ + 'attachment_id' => (int) ($attachment['id'] ?? 0), + 'file_path' => $attachment['file_path'] ?? null, + 'reason' => 'File missing or not readable', + ]; + } + + $summary['files'][] = $entry; + } + + $this->writeJson($attachmentsDir . '/index.json', $summary); + + return [ + 'total_records' => $summary['total_records'], + 'copied_files' => $summary['copied_files'], + 'skipped_files' => count($summary['skipped_files']), + ]; + } + + private function exportServers(\PDO $pdo, array $context, string $exportDir, string $exportUuid): array + { + $serversDir = $exportDir . '/featherpanel/servers'; + $this->prepareDirectory($serversDir); + + $summary = [ + 'total_servers' => count($context['server_ids']), + 'file_exports' => [], + 'backup_requests' => [], + 'database_dumps' => [], + 'skipped' => [], + ]; + + foreach ($context['server_ids'] as $serverId) { + $server = $this->fetchRowById($pdo, 'featherpanel_servers', (int) $serverId); + if ($server === null) { + $summary['skipped'][] = ['server_id' => (int) $serverId, 'reason' => 'Server row not found']; + continue; + } + + $serverFolderName = $this->sanitizePathSegment((string) ($server['uuid'] ?? $server['uuidShort'] ?? $server['id'])); + $serverDir = $serversDir . '/' . $serverFolderName; + $this->prepareDirectory($serverDir); + + $this->writeJson($serverDir . '/server.json', ['server' => $this->sanitizeRows([$server])[0]]); + $this->writeJson($serverDir . '/existing_backups.json', [ + 'backups' => $this->sanitizeRows($this->fetchRowsByColumn($pdo, 'featherpanel_server_backups', 'server_id', (int) $server['id'])), + ]); + $fileExport = $this->exportServerFilesFromBackup($server, $serverDir, $exportUuid); + $summary['file_exports'][] = $fileExport; + $this->writeJson($serverDir . '/files_index.json', $fileExport); + + $databaseDump = $this->exportServerDatabases($pdo, $server, $serverDir, $context); + $summary['database_dumps'][] = $databaseDump; + $this->writeJson($serverDir . '/databases/index.json', $databaseDump); + + $backupResult = $fileExport['backup'] ?? [ + 'status' => 'failed', + 'message' => 'Backup export did not return backup metadata', + ]; + $summary['backup_requests'][] = $backupResult; + $this->writeJson($serverDir . '/export_backup_request.json', $backupResult); + } + + $this->writeJson($serversDir . '/index.json', $summary); + + return [ + 'total_servers' => $summary['total_servers'], + 'file_exports' => count($summary['file_exports']), + 'backup_requests' => count($summary['backup_requests']), + 'database_dumps' => array_sum(array_map(fn (array $entry): int => (int) ($entry['dumped_databases'] ?? 0), $summary['database_dumps'])), + 'skipped' => count($summary['skipped']), + ]; + } + + private function exportServerFilesFromBackup(array $server, string $serverDir, string $exportUuid): array + { + $filesDir = $serverDir . '/files'; + $archiveDir = $serverDir . '/backup_archive'; + $this->prepareDirectory($filesDir); + $this->prepareDirectory($archiveDir); + + $index = [ + 'server_id' => (int) $server['id'], + 'server_uuid' => $server['uuid'] ?? null, + 'method' => 'wings_backup_download_extract', + 'export_path' => 'files', + 'archive_path' => null, + 'backup' => null, + 'extracted' => false, + 'skipped' => [], + ]; + + $backup = $this->requestServerBackup($server, $exportUuid); + $index['backup'] = $backup; + + if (($backup['status'] ?? '') !== 'requested') { + $index['skipped'][] = ['path' => '/', 'reason' => $backup['message'] ?? 'Backup request failed']; + + return $index; + } + + $completedBackup = $this->waitForBackupCompletion((string) $backup['backup_uuid']); + if ($completedBackup === null) { + $index['skipped'][] = [ + 'path' => '/', + 'reason' => 'Backup was requested but did not finish within ' . self::BACKUP_WAIT_SECONDS . ' seconds', + ]; + + return $index; + } + + try { + $archivePath = $archiveDir . '/' . $this->sanitizePathSegment((string) $backup['backup_uuid']) . '.tar.gz'; + $this->downloadBackupArchive($server, $completedBackup, $archivePath); + $index['archive_path'] = 'backup_archive/' . basename($archivePath); + + $extractResult = $this->extractBackupArchive($archivePath, $filesDir); + $index['extracted'] = $extractResult['success']; + $index['extraction'] = $extractResult; + if (!$extractResult['success']) { + $index['skipped'][] = ['path' => '/', 'reason' => $extractResult['message']]; + } + } catch (\Throwable $e) { + $index['skipped'][] = ['path' => '/', 'reason' => $e->getMessage()]; + } + + return $index; + } + + private function exportServerDatabases(\PDO $appPdo, array $server, string $serverDir, array $context): array + { + $databasesDir = $serverDir . '/databases'; + $this->prepareDirectory($databasesDir); + + $summary = [ + 'server_id' => (int) $server['id'], + 'server_uuid' => $server['uuid'] ?? null, + 'dump_allowed' => in_array((int) $server['id'], $context['database_dump_server_ids'], true), + 'total_databases' => 0, + 'dumped_databases' => 0, + 'skipped' => [], + 'files' => [], + ]; + + $databases = $this->fetchServerDatabasesWithDetails($appPdo, (int) $server['id']); + $summary['total_databases'] = count($databases); + + if (!$summary['dump_allowed']) { + foreach ($databases as $database) { + $summary['skipped'][] = [ + 'database_id' => (int) ($database['id'] ?? 0), + 'database' => $database['database'] ?? null, + 'reason' => 'The user can access this server, but does not have database password/export access.', + ]; + } + + return $summary; + } + + foreach ($databases as $database) { + $databaseName = (string) ($database['database'] ?? ('database_' . ($database['id'] ?? uniqid()))); + $targetName = $this->sanitizePathSegment($databaseName) . '.sql'; + $targetPath = $databasesDir . '/' . $targetName; + + try { + $result = $this->buildMysqlDatabaseDump($database, $targetPath); + ++$summary['dumped_databases']; + $summary['files'][] = [ + 'database_id' => (int) ($database['id'] ?? 0), + 'database' => $databaseName, + 'export_path' => 'databases/' . $targetName, + 'table_count' => $result['table_count'], + 'row_limit_per_table' => $result['row_limit_per_table'], + 'chunk_size' => $result['chunk_size'], + ]; + } catch (\Throwable $e) { + $summary['skipped'][] = [ + 'database_id' => (int) ($database['id'] ?? 0), + 'database' => $databaseName, + 'reason' => $e->getMessage(), + ]; + } + } + + return $summary; + } + + private function fetchServerDatabasesWithDetails(\PDO $pdo, int $serverId): array + { + if (!$this->tableExists($pdo, 'featherpanel_server_databases') || !$this->tableExists($pdo, 'featherpanel_databases')) { + return []; + } + + $stmt = $pdo->prepare( + 'SELECT server_databases.*, database_hosts.database_type, database_hosts.database_host, database_hosts.database_port + FROM `featherpanel_server_databases` server_databases + INNER JOIN `featherpanel_databases` database_hosts ON database_hosts.id = server_databases.database_host_id + WHERE server_databases.server_id = :server_id + ORDER BY server_databases.id ASC' + ); + $stmt->execute(['server_id' => $serverId]); + + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + private function buildMysqlDatabaseDump(array $database, string $targetPath): array + { + $databaseType = strtolower((string) ($database['database_type'] ?? '')); + if (!in_array($databaseType, ['mysql', 'mariadb'], true)) { + throw new \RuntimeException('SQL dump export is only supported for MySQL/MariaDB databases'); + } + + $dbName = (string) ($database['database'] ?? ''); + $host = (string) ($database['database_host'] ?? ''); + $username = (string) ($database['username'] ?? ''); + $password = (string) ($database['password'] ?? ''); + $port = (int) ($database['database_port'] ?? 3306); + + if ($dbName === '' || $host === '' || $username === '') { + throw new \RuntimeException('Database connection details are incomplete'); + } + + $pdo = new \PDO( + "mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4", + $username, + $password, + [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::ATTR_TIMEOUT => 30] + ); + + $tables = $pdo->query('SHOW TABLES')->fetchAll(\PDO::FETCH_COLUMN); + $chunkSize = 250; + $handle = fopen($targetPath, 'wb'); + if ($handle === false) { + throw new \RuntimeException('Failed to open SQL dump for writing'); + } + + $write = static function (string $line = '') use ($handle): void { + if (fwrite($handle, $line . "\n") === false) { + throw new \RuntimeException('Failed to write SQL dump'); + } + }; + + try { + $write('-- FeatherPanel SQL Dump'); + $write('-- Database: ' . $dbName); + $write('-- Generated: ' . gmdate('Y-m-d H:i:s') . ' UTC'); + $write('-- Host: ' . $host . ':' . $port); + $write(); + $write('SET FOREIGN_KEY_CHECKS=0;'); + $write("SET SQL_MODE='NO_AUTO_VALUE_ON_ZERO';"); + $write("SET time_zone='+00:00';"); + $write(); + + foreach ($tables as $table) { + $safeTable = $this->quoteMysqlIdentifier((string) $table); + $write('-- Table: ' . $table); + $write('DROP TABLE IF EXISTS ' . $safeTable . ';'); + + $createRow = $pdo->query('SHOW CREATE TABLE ' . $safeTable)->fetch(\PDO::FETCH_NUM); + $write(((string) ($createRow[1] ?? '')) . ';'); + $write(); + + $offset = 0; + while (true) { + $rows = $pdo->query('SELECT * FROM ' . $safeTable . ' LIMIT ' . $chunkSize . ' OFFSET ' . $offset)->fetchAll(\PDO::FETCH_ASSOC); + if (empty($rows)) { + break; + } + + $columns = '(' . implode(', ', array_map(fn ($column): string => $this->quoteMysqlIdentifier((string) $column), array_keys($rows[0]))) . ')'; + $valuesList = array_map(function (array $row) use ($pdo): string { + $values = array_map(function ($value) use ($pdo): string { + if ($value === null) { + return 'NULL'; + } + + return $pdo->quote((string) $value); + }, array_values($row)); + + return '(' . implode(', ', $values) . ')'; + }, $rows); + + $write('INSERT INTO ' . $safeTable . ' ' . $columns . ' VALUES'); + $write(implode(",\n", $valuesList) . ';'); + $offset += $chunkSize; + } + + if ($offset > 0) { + $write(); + } + } + + $write('SET FOREIGN_KEY_CHECKS=1;'); + } finally { + fclose($handle); + } + + return [ + 'table_count' => count($tables), + 'row_limit_per_table' => null, + 'chunk_size' => $chunkSize, + ]; + } + + private function quoteMysqlIdentifier(string $identifier): string + { + return '`' . str_replace('`', '``', $identifier) . '`'; + } + + private function requestServerBackup(array $server, string $exportUuid): array + { + $backupUuid = $this->generateUuid(); + $result = [ + 'server_id' => (int) $server['id'], + 'server_uuid' => $server['uuid'] ?? null, + 'backup_uuid' => $backupUuid, + 'status' => 'skipped', + 'message' => null, + ]; + + try { + $node = Node::getNodeById((int) $server['node_id']); + if (!$node) { + $result['message'] = 'Node not found'; + + return $result; + } + + $backupId = Backup::createBackup([ + 'server_id' => (int) $server['id'], + 'uuid' => $backupUuid, + 'name' => 'Personal data export backup ' . $exportUuid, + 'ignored_files' => '[]', + 'disk' => 'wings', + 'is_successful' => 0, + 'is_locked' => 1, + ]); + + if (!$backupId) { + $result['status'] = 'failed'; + $result['message'] = 'Failed to create backup database record'; + + return $result; + } + + $wings = new Wings( + $node['fqdn'], + (int) $node['daemonListen'], + $node['scheme'], + $node['daemon_token'], + 30 + ); + $response = $wings->getServer()->createBackup((string) $server['uuid'], 'wings', $backupUuid, '[]'); + + if (!$response->isSuccessful()) { + Backup::deleteBackup((int) $backupId); + $result['status'] = 'failed'; + $result['message'] = $response->getError(); + + return $result; + } + + $result['status'] = 'requested'; + $result['backup_id'] = (int) $backupId; + $result['message'] = 'Wings backup requested. The export worker will wait briefly for it, download it, and extract it into this export.'; + + return $result; + } catch (\Throwable $e) { + $result['status'] = 'failed'; + $result['message'] = $e->getMessage(); + + return $result; + } + } + + private function waitForBackupCompletion(string $backupUuid): ?array + { + $deadline = time() + self::BACKUP_WAIT_SECONDS; + while (true) { + $backup = Backup::getBackupByUuid($backupUuid); + if ($backup && (int) ($backup['is_successful'] ?? 0) === 1) { + return $backup; + } + + if (time() >= $deadline) { + break; + } + + sleep(self::BACKUP_WAIT_INTERVAL_SECONDS); + } + + return null; + } + + private function downloadBackupArchive(array $server, array $backup, string $archivePath): void + { + $node = Node::getNodeById((int) $server['node_id']); + if (!$node) { + throw new \RuntimeException('Node not found for backup download'); + } + + $owner = $this->fetchRowById(Database::getPdoConnection(), 'featherpanel_users', (int) $server['owner_id']); + $ownerUuid = (string) ($owner['uuid'] ?? ''); + if ($ownerUuid === '') { + throw new \RuntimeException('Server owner not found for backup download token'); + } + + $baseWingsUrl = rtrim((string) $node['scheme'] . '://' . $node['fqdn'] . ':' . $node['daemonListen'], '/'); + $jwtService = new JwtService( + (string) $node['daemon_token'], + App::getInstance(true)->getConfig()->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.local'), + $baseWingsUrl + ); + + $jwtToken = $jwtService->generateBackupToken( + (string) $server['uuid'], + $ownerUuid, + ['backup.download'], + (string) $backup['uuid'], + 'download' + ); + + $downloadUrl = $baseWingsUrl . '/download/backup?token=' . rawurlencode($jwtToken) + . '&server=' . rawurlencode((string) $server['uuid']) + . '&backup=' . rawurlencode((string) $backup['uuid']); + + $client = new Client([ + 'timeout' => 300, + 'verify' => false, + 'http_errors' => false, + ]); + $response = $client->get($downloadUrl, ['sink' => $archivePath]); + $statusCode = $response->getStatusCode(); + if ($statusCode < 200 || $statusCode >= 300) { + @unlink($archivePath); + throw new \RuntimeException('Backup archive download failed with HTTP status ' . $statusCode); + } + if (!is_file($archivePath) || filesize($archivePath) === 0) { + @unlink($archivePath); + throw new \RuntimeException('Backup archive download produced an empty file'); + } + } + + private function extractBackupArchive(string $archivePath, string $targetDir): array + { + $this->prepareDirectory($targetDir); + + $zip = new \ZipArchive(); + $opened = $zip->open($archivePath); + if ($opened === true) { + $extractedFiles = 0; + for ($i = 0; $i < $zip->numFiles; ++$i) { + $name = $zip->getNameIndex($i); + if (!is_string($name) || !$this->isSafeArchivePath($name)) { + continue; + } + $zip->extractTo($targetDir, $name); + ++$extractedFiles; + } + $zip->close(); + + return [ + 'success' => true, + 'format' => 'zip', + 'message' => 'Backup archive extracted', + 'entries' => $extractedFiles, + ]; + } + + try { + $tarPath = $archivePath; + if (str_ends_with($archivePath, '.gz')) { + $phar = new \PharData($archivePath); + $tarPath = substr($archivePath, 0, -3); + if (!is_file($tarPath)) { + $phar->decompress(); + } + } + + $phar = new \PharData($tarPath); + $phar->extractTo($targetDir, null, true); + + return [ + 'success' => true, + 'format' => str_ends_with($archivePath, '.gz') ? 'tar.gz' : 'tar', + 'message' => 'Backup archive extracted', + ]; + } catch (\Throwable $e) { + return [ + 'success' => false, + 'format' => 'unknown', + 'message' => 'Failed to extract backup archive: ' . $e->getMessage(), + ]; + } + } + + private function isSafeArchivePath(string $path): bool + { + $normalized = str_replace('\\', '/', $path); + + return !str_starts_with($normalized, '/') + && !str_contains($normalized, '../') + && !str_contains($normalized, '..\\') + && $normalized !== '..'; + } + + private function buildOrderByClause(array $columns): string + { + if (in_array('id', $columns, true)) { + return ' ORDER BY `id` ASC'; + } + + if (in_array('created_at', $columns, true)) { + return ' ORDER BY `created_at` ASC'; + } + + return ''; + } + + private function fetchRowById(\PDO $pdo, string $table, int $id): ?array + { + if ($id <= 0 || !$this->tableExists($pdo, $table)) { + return null; + } + + $columns = $this->getColumns($pdo, $table); + if (!in_array('id', $columns, true)) { + return null; + } + + $stmt = $pdo->prepare('SELECT * FROM `' . $table . '` WHERE `id` = :id LIMIT 1'); + $stmt->execute(['id' => $id]); + + return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + } + + private function fetchRowsByIds(\PDO $pdo, string $table, array $ids): array + { + $ids = $this->uniqueInts($ids); + if (empty($ids) || !$this->tableExists($pdo, $table)) { + return []; + } + + $columns = $this->getColumns($pdo, $table); + if (!in_array('id', $columns, true)) { + return []; + } + + $params = []; + $placeholders = []; + foreach ($ids as $id) { + $param = 'p' . count($params); + $placeholders[] = ':' . $param; + $params[$param] = $id; + } + + $stmt = $pdo->prepare('SELECT * FROM `' . $table . '` WHERE `id` IN (' . implode(', ', $placeholders) . ')'); + $stmt->execute($params); + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); + $mapped = []; + foreach ($rows as $row) { + $mapped[(int) $row['id']] = $row; + } + + return $mapped; + } + + private function fetchRowsByColumn(\PDO $pdo, string $table, string $column, mixed $value): array + { + if (!$this->tableExists($pdo, $table)) { + return []; + } + + $columns = $this->getColumns($pdo, $table); + if (!in_array($column, $columns, true)) { + return []; + } + + $stmt = $pdo->prepare('SELECT * FROM `' . $table . '` WHERE `' . $column . '` = :value' . $this->buildOrderByClause($columns)); + $stmt->execute(['value' => $value]); + + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + private function resolvePublicFilePath(string $filePath): ?string + { + $normalizedPath = ltrim($filePath, '/'); + if ($normalizedPath === '') { + return null; + } + + $publicRoot = $this->getPublicRoot(); + $fullPath = realpath($publicRoot . '/' . $normalizedPath); + $publicRealPath = realpath($publicRoot); + + if ($fullPath === false || $publicRealPath === false || !str_starts_with($fullPath, $publicRealPath . DIRECTORY_SEPARATOR)) { + return null; + } + + return $fullPath; + } + + private function generateUuid(): string + { + $bytes = random_bytes(16); + $bytes[6] = chr(ord($bytes[6]) & 0x0F | 0x40); + $bytes[8] = chr(ord($bytes[8]) & 0x3F | 0x80); + $hex = bin2hex($bytes); + + return sprintf( + '%s-%s-%s-%s-%s', + substr($hex, 0, 8), + substr($hex, 8, 4), + substr($hex, 12, 4), + substr($hex, 16, 4), + substr($hex, 20, 12) + ); + } + + private function addEqualsFilter(array &$clauses, array &$params, array $columns, string $column, mixed $value, bool $enabled = true): void + { + if (!$enabled || !in_array($column, $columns, true) || $value === null || $value === '') { + return; + } + + $param = 'p' . count($params); + $clauses[] = '`' . $column . '` = :' . $param; + $params[$param] = $value; + } + + private function addInFilter(array &$clauses, array &$params, array $columns, string $column, array $values): void + { + if (!in_array($column, $columns, true) || empty($values)) { + return; + } + + $placeholders = []; + foreach ($values as $value) { + $param = 'p' . count($params); + $placeholders[] = ':' . $param; + $params[$param] = $value; + } + + $clauses[] = '`' . $column . '` IN (' . implode(', ', $placeholders) . ')'; + } + + private function addExactIdFilter(array &$clauses, array &$params, string $table, array $columns, string $targetTable, array $ids): void + { + if ($table !== $targetTable || !in_array('id', $columns, true) || empty($ids)) { + return; + } + + $this->addInFilter($clauses, $params, $columns, 'id', $ids); + } + + private function selectIds(\PDO $pdo, string $table, array $conditions, string $idColumn = 'id'): array + { + if (!$this->tableExists($pdo, $table)) { + return []; + } + + $columns = $this->getColumns($pdo, $table); + if (!in_array($idColumn, $columns, true)) { + return []; + } + + $where = []; + $params = []; + foreach ($conditions as $column => $value) { + if (!in_array($column, $columns, true) || $value === null || $value === '') { + return []; + } + $where[] = '`' . $column . '` = :' . $column; + $params[$column] = $value; + } + + $stmt = $pdo->prepare('SELECT `' . $idColumn . '` FROM `' . $table . '` WHERE ' . implode(' AND ', $where)); + $stmt->execute($params); + + return $this->uniqueInts($stmt->fetchAll(\PDO::FETCH_COLUMN)); + } + + private function selectIdsIn(\PDO $pdo, string $table, string $column, array $values, string $idColumn = 'id'): array + { + if (!$this->tableExists($pdo, $table) || empty($values)) { + return []; + } + + $columns = $this->getColumns($pdo, $table); + if (!in_array($idColumn, $columns, true) || !in_array($column, $columns, true)) { + return []; + } + + $params = []; + $placeholders = []; + foreach ($values as $value) { + $param = 'p' . count($params); + $placeholders[] = ':' . $param; + $params[$param] = $value; + } + + $stmt = $pdo->prepare('SELECT `' . $idColumn . '` FROM `' . $table . '` WHERE `' . $column . '` IN (' . implode(', ', $placeholders) . ')'); + $stmt->execute($params); + + return $this->uniqueInts($stmt->fetchAll(\PDO::FETCH_COLUMN)); + } + + private function selectSubuserServerIdsWithPermission(\PDO $pdo, int $userId, string $permission): array + { + if (!$this->tableExists($pdo, 'featherpanel_server_subusers') || $userId <= 0 || $permission === '') { + return []; + } + + $stmt = $pdo->prepare('SELECT `server_id`, `permissions` FROM `featherpanel_server_subusers` WHERE `user_id` = :user_id'); + $stmt->execute(['user_id' => $userId]); + + $serverIds = []; + foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) { + $permissions = json_decode((string) ($row['permissions'] ?? '[]'), true); + if (!is_array($permissions)) { + continue; + } + if (in_array('*', $permissions, true) || in_array($permission, $permissions, true)) { + $serverIds[] = (int) $row['server_id']; + } + } + + return $this->uniqueInts($serverIds); + } + + private function selectVisibleTicketMessageIds(\PDO $pdo, array $ticketIds): array + { + if (!$this->tableExists($pdo, 'featherpanel_ticket_messages') || empty($ticketIds)) { + return []; + } + + $params = []; + $placeholders = []; + foreach ($ticketIds as $ticketId) { + $param = 'p' . count($params); + $placeholders[] = ':' . $param; + $params[$param] = $ticketId; + } + + $stmt = $pdo->prepare( + 'SELECT `id` FROM `featherpanel_ticket_messages` + WHERE `ticket_id` IN (' . implode(', ', $placeholders) . ') + AND `is_internal` = 0' + ); + $stmt->execute($params); + + return $this->uniqueInts($stmt->fetchAll(\PDO::FETCH_COLUMN)); + } + + private function sanitizeRows(array $rows): array + { + return array_map(function (array $row): array { + foreach ($row as $column => $value) { + if ($this->isSecretColumn((string) $column)) { + $row[$column] = $this->secretMetadata($value); + } + } + + return $row; + }, $rows); + } + + private function isSecretColumn(string $column): bool + { + $normalized = strtolower($column); + + foreach ($this->secretColumns as $secretColumn) { + if ($normalized === $secretColumn || str_contains($normalized, $secretColumn)) { + return true; + } + } + + return false; + } + + private function secretMetadata(mixed $value): array + { + if ($value === null || $value === '') { + return [ + 'present' => false, + 'length' => 0, + ]; + } + + return [ + 'present' => true, + 'length' => strlen((string) $value), + 'sha256' => hash('sha256', (string) $value), + 'redacted' => true, + ]; + } + + private function writeJson(string $path, array $data): void + { + $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($encoded === false) { + throw new \RuntimeException('Failed to encode export JSON'); + } + + if (file_put_contents($path, $encoded . PHP_EOL) === false) { + throw new \RuntimeException('Failed to write export JSON: ' . $path); + } + } + + private function getTables(\PDO $pdo): array + { + if (!empty($this->tableCache)) { + return $this->tableCache; + } + + $stmt = $pdo->query('SHOW TABLES'); + $this->tableCache = $stmt->fetchAll(\PDO::FETCH_COLUMN); + + return $this->tableCache; + } + + private function tableExists(\PDO $pdo, string $table): bool + { + return in_array($table, $this->getTables($pdo), true); + } + + private function getColumns(\PDO $pdo, string $table): array + { + if (isset($this->columnCache[$table])) { + return $this->columnCache[$table]; + } + + if (!$this->tableExists($pdo, $table)) { + return []; + } + + $stmt = $pdo->query('DESCRIBE `' . $table . '`'); + $this->columnCache[$table] = array_map( + fn (array $column): string => $column['Field'], + $stmt->fetchAll(\PDO::FETCH_ASSOC) + ); + + return $this->columnCache[$table]; + } + + private function uniqueInts(array $values): array + { + $ints = array_map('intval', $values); + $ints = array_filter($ints, fn (int $value): bool => $value > 0); + + return array_values(array_unique($ints)); + } + + private function getStorageRoot(): string + { + if (defined('APP_STORAGE_DIR')) { + $base = realpath((string) APP_STORAGE_DIR); + if ($base === false) { + $base = $this->normalizePath((string) APP_STORAGE_DIR); + } + } else { + $base = dirname(__DIR__, 4) . '/storage'; + } + + return rtrim($base, '/\\') . '/user-data-exports'; + } + + private function getPublicRoot(): string + { + if (defined('APP_DIR')) { + $publicRoot = realpath(rtrim((string) APP_DIR, '/\\') . '/public'); + if ($publicRoot !== false) { + return $publicRoot; + } + + return $this->normalizePath(rtrim((string) APP_DIR, '/\\') . '/public'); + } + + return dirname(__DIR__, 4) . '/public'; + } + + private function normalizePath(string $path): string + { + $path = str_replace('\\', '/', $path); + $isAbsolute = str_starts_with($path, '/'); + $parts = []; + + foreach (explode('/', $path) as $part) { + if ($part === '' || $part === '.') { + continue; + } + if ($part === '..') { + array_pop($parts); + continue; + } + $parts[] = $part; + } + + return ($isAbsolute ? '/' : '') . implode('/', $parts); + } + + private function prepareDirectory(string $path): void + { + if (is_dir($path)) { + if (!is_writable($path)) { + throw new \RuntimeException('Export directory is not writable: ' . $path); + } + + return; + } + + $parent = dirname($path); + if (is_dir($parent) && !is_writable($parent)) { + throw new \RuntimeException('Export directory parent is not writable: ' . $parent); + } + + $previousError = error_get_last(); + if (!@mkdir($path, 0770, true) && !is_dir($path)) { + $error = error_get_last(); + $message = is_array($error) && $error !== $previousError && isset($error['message']) + ? ' (' . $error['message'] . ')' + : ''; + + throw new \RuntimeException('Failed to create export directory: ' . $path . $message); + } + + @chmod($path, 0770); + clearstatcache(true, $path); + if (!is_writable($path)) { + throw new \RuntimeException('Created export directory is not writable: ' . $path); + } + } + + private function sanitizePathSegment(string $segment): string + { + return preg_replace('/[^a-zA-Z0-9._-]/', '_', $segment) ?: 'export'; + } +} diff --git a/backend/app/Services/Wings/Services/ServerService.php b/backend/app/Services/Wings/Services/ServerService.php index 81bb6bf8c..c53755441 100755 --- a/backend/app/Services/Wings/Services/ServerService.php +++ b/backend/app/Services/Wings/Services/ServerService.php @@ -228,12 +228,18 @@ public function reinstallServer(string $serverUuid): WingsResponse /** * List items in a directory. + * + * @param bool $includeDirectorySizes when true, requests Wings recursive per-folder sizes (cached on the daemon) */ - public function listDirectory(string $serverUuid, string $directory = '/'): WingsResponse + public function listDirectory(string $serverUuid, string $directory = '/', bool $includeDirectorySizes = false): WingsResponse { try { $encodedDirectory = urlencode($directory); - $response = $this->connection->get("/api/servers/{$serverUuid}/files/list-directory?directory={$encodedDirectory}"); + $query = "directory={$encodedDirectory}"; + if ($includeDirectorySizes) { + $query .= '&directory_sizes=true'; + } + $response = $this->connection->get("/api/servers/{$serverUuid}/files/list-directory?{$query}"); return new WingsResponse($response, 200); } catch (\Exception $e) { @@ -259,6 +265,27 @@ public function searchFiles(string $serverUuid, array $filters = []): WingsRespo } } + /** + * List one directory inside an on-disk archive without extracting (supported formats only). + * + * @param string $innerPath Path inside the archive (empty string = root) + */ + public function listArchiveDirectory(string $serverUuid, string $directory, string $file, string $innerPath = ''): WingsResponse + { + try { + $query = http_build_query([ + 'directory' => $directory, + 'file' => $file, + 'path' => $innerPath, + ]); + $response = $this->connection->get("/api/servers/{$serverUuid}/files/archive/list?{$query}"); + + return new WingsResponse($response, 200); + } catch (\Exception $e) { + return new WingsResponse(['error' => $e->getMessage()], 500); + } + } + /** * Get file contents. */ @@ -363,14 +390,25 @@ public function copyFiles(string $serverUuid, string $location, array $files): W /** * Delete files/directories. + * + * @param array $options Optional keys: use_trash (bool), permanent (bool), trash (array{max_size_bytes?: int, retention_days?: int}) */ - public function deleteFiles(string $serverUuid, string $root, array $files): WingsResponse + public function deleteFiles(string $serverUuid, string $root, array $files, array $options = []): WingsResponse { try { $data = [ 'root' => $root, 'files' => $files, ]; + if (isset($options['use_trash'])) { + $data['use_trash'] = (bool) $options['use_trash']; + } + if (isset($options['permanent'])) { + $data['permanent'] = (bool) $options['permanent']; + } + if (!empty($options['trash']) && is_array($options['trash'])) { + $data['trash'] = $options['trash']; + } $response = $this->connection->post("/api/servers/{$serverUuid}/files/delete", $data); return new WingsResponse($response, 204); @@ -379,6 +417,73 @@ public function deleteFiles(string $serverUuid, string $root, array $files): Win } } + /** + * List trashed files for a server. + */ + public function listTrash(string $serverUuid, int $maxSizeBytes = 0, int $retentionDays = 0): WingsResponse + { + try { + $query = http_build_query([ + 'max_size_bytes' => $maxSizeBytes, + 'retention_days' => $retentionDays, + ]); + $response = $this->connection->get("/api/servers/{$serverUuid}/files/trash?{$query}"); + + return new WingsResponse($response, 200); + } catch (\Exception $e) { + return new WingsResponse(['error' => $e->getMessage()], 500); + } + } + + /** + * Restore files from trash. + */ + public function restoreTrash(string $serverUuid, array $ids, bool $overwrite = false): WingsResponse + { + try { + $this->connection->post("/api/servers/{$serverUuid}/files/trash/restore", [ + 'ids' => $ids, + 'overwrite' => $overwrite, + ]); + + return new WingsResponse([], 204); + } catch (WingsRequestException $e) { + return new WingsResponse(['error' => $e->getMessage()], $e->getCode()); + } catch (\Exception $e) { + return new WingsResponse(['error' => $e->getMessage()], 500); + } + } + + /** + * Permanently delete selected trash entries. + */ + public function deleteTrashEntries(string $serverUuid, array $ids): WingsResponse + { + try { + $response = $this->connection->post("/api/servers/{$serverUuid}/files/trash/delete", [ + 'ids' => $ids, + ]); + + return new WingsResponse($response, 204); + } catch (\Exception $e) { + return new WingsResponse(['error' => $e->getMessage()], 500); + } + } + + /** + * Empty the entire trash bin for a server. + */ + public function emptyTrash(string $serverUuid): WingsResponse + { + try { + $response = $this->connection->delete("/api/servers/{$serverUuid}/files/trash"); + + return new WingsResponse($response, 204); + } catch (\Exception $e) { + return new WingsResponse(['error' => $e->getMessage()], 500); + } + } + /** * Create directory. */ @@ -489,6 +594,36 @@ public function decompressArchive(string $serverUuid, string $file, string $root } } + /** + * Extract selected paths from an on-disk archive without unpacking the whole archive (Wings 204). + * + * @param array $entries Paths inside the archive (files and/or directories) + */ + public function extractArchiveSelection( + string $serverUuid, + string $root, + string $file, + string $destination, + array $entries, + ?int $timeout = null, + ): WingsResponse { + try { + $data = [ + 'root' => $root, + 'file' => $file, + 'destination' => $destination, + 'entries' => array_values($entries), + ]; + + $requestTimeout = $timeout ?? (60 * 15); + $response = $this->connection->post("/api/servers/{$serverUuid}/files/archive/extract", $data, [], 3, $requestTimeout); + + return new WingsResponse($response, 204); + } catch (\Exception $e) { + return new WingsResponse(['error' => $e->getMessage()], 500); + } + } + /** * Change file permissions (chmod). */ @@ -619,6 +754,8 @@ public function deleteBackup(string $serverUuid, string $backupId): WingsRespons $response = $this->connection->delete("/api/servers/{$serverUuid}/backup/{$backupId}"); return new WingsResponse($response, 204); + } catch (WingsAuthenticationException | WingsRequestException $e) { + return new WingsResponse(['error' => $e->getMessage()], $e->getCode() > 0 ? $e->getCode() : 500); } catch (\Exception $e) { return new WingsResponse(['error' => $e->getMessage()], 500); } diff --git a/backend/app/Services/Wings/WingsConnection.php b/backend/app/Services/Wings/WingsConnection.php index 4ebf814cf..478f46074 100755 --- a/backend/app/Services/Wings/WingsConnection.php +++ b/backend/app/Services/Wings/WingsConnection.php @@ -632,7 +632,7 @@ private function handleHttpError(int $httpCode, ?array $responseData, string $en case 403: throw new WingsAuthenticationException("Access forbidden: {$errorDetails}", 403); case 404: - throw new WingsRequestException("Endpoint not found: {$endpoint}", 404); + throw new WingsRequestException("Wings request failed (404) for {$endpoint}: {$errorDetails}", 404); case 429: throw new WingsRequestException("Rate limit exceeded: {$errorDetails}", 429); case 500: diff --git a/backend/app/routes/admin/allocations.php b/backend/app/routes/admin/allocations.php index e1b7f7fc5..61ed0a4b0 100755 --- a/backend/app/routes/admin/allocations.php +++ b/backend/app/routes/admin/allocations.php @@ -82,6 +82,18 @@ function (Request $request) { ['DELETE'] ); + // Bulk update address - PATCH /api/admin/allocations/bulk-address + App::getInstance(true)->registerAdminRoute( + $routes, + 'admin-allocations-bulk-address', + '/api/admin/allocations/bulk-address', + function (Request $request) { + return (new AllocationsController())->bulkUpdateAddress($request); + }, + Permissions::ADMIN_ALLOCATIONS_EDIT, + ['PATCH'] + ); + // PARAMETERIZED ROUTES (must come AFTER specific routes) // Show single - GET /api/admin/allocations/{id} App::getInstance(true)->registerAdminRoute( diff --git a/backend/app/routes/admin/nodes.php b/backend/app/routes/admin/nodes.php index 4ce266858..0648dd84c 100755 --- a/backend/app/routes/admin/nodes.php +++ b/backend/app/routes/admin/nodes.php @@ -290,4 +290,36 @@ function (Request $request, array $args) { Permissions::ADMIN_NODES_EDIT, ['PUT', 'POST'] ); + + App::getInstance(true)->registerAdminRoute( + $routes, + 'admin-nodes-mass-transfer-preview', + '/api/admin/nodes/{id}/mass-transfer/preview', + function (Request $request, array $args) { + $id = $args['id'] ?? null; + if (!$id || !is_numeric($id)) { + return ApiResponse::error('Missing or invalid ID', 'INVALID_ID', 400); + } + + return (new NodesController())->previewMassTransfer($request, (int) $id); + }, + Permissions::ADMIN_SERVERS_EDIT, + ['GET'] + ); + + App::getInstance(true)->registerAdminRoute( + $routes, + 'admin-nodes-mass-transfer', + '/api/admin/nodes/{id}/mass-transfer', + function (Request $request, array $args) { + $id = $args['id'] ?? null; + if (!$id || !is_numeric($id)) { + return ApiResponse::error('Missing or invalid ID', 'INVALID_ID', 400); + } + + return (new NodesController())->massTransfer($request, (int) $id); + }, + Permissions::ADMIN_SERVERS_EDIT, + ['POST'] + ); }; diff --git a/backend/app/routes/admin/servers.php b/backend/app/routes/admin/servers.php index 390b1662d..b91ac8304 100755 --- a/backend/app/routes/admin/servers.php +++ b/backend/app/routes/admin/servers.php @@ -227,6 +227,42 @@ function (Request $request, array $args) { Permissions::ADMIN_SERVERS_VIEW, ); + App::getInstance(true)->registerAdminRoute( + $routes, + 'admin-servers-custom-variable-create', + '/api/admin/servers/{id}/custom-variables', + function (Request $request, array $args) { + $id = $args['id'] ?? null; + if (!$id || !is_numeric($id)) { + return ApiResponse::error('Missing or invalid server ID', 'INVALID_SERVER_ID', 400); + } + + return (new ServersController())->createCustomVariable($request, (int) $id); + }, + Permissions::ADMIN_SERVERS_EDIT, + ['POST'] + ); + + App::getInstance(true)->registerAdminRoute( + $routes, + 'admin-servers-custom-variable-delete', + '/api/admin/servers/{id}/custom-variables/{variableId}', + function (Request $request, array $args) { + $id = $args['id'] ?? null; + $variableId = $args['variableId'] ?? null; + if (!$id || !is_numeric($id)) { + return ApiResponse::error('Missing or invalid server ID', 'INVALID_SERVER_ID', 400); + } + if (!$variableId || !is_numeric($variableId)) { + return ApiResponse::error('Missing or invalid variable ID', 'INVALID_VARIABLE_ID', 400); + } + + return (new ServersController())->deleteCustomVariable($request, (int) $id, (int) $variableId); + }, + Permissions::ADMIN_SERVERS_EDIT, + ['DELETE'] + ); + // Suspend a server App::getInstance(true)->registerAdminRoute( $routes, diff --git a/backend/app/routes/admin/spells.php b/backend/app/routes/admin/spells.php index 5a8b162f2..53668dfff 100755 --- a/backend/app/routes/admin/spells.php +++ b/backend/app/routes/admin/spells.php @@ -86,6 +86,16 @@ function (Request $request) { Permissions::ADMIN_SPELLS_CREATE, ['PUT'] ); + App::getInstance(true)->registerAdminRoute( + $routes, + 'admin-spells-reorder', + '/api/admin/spells/reorder', + function (Request $request) { + return (new SpellsController())->reorder($request); + }, + Permissions::ADMIN_SPELLS_EDIT, + ['POST'] + ); App::getInstance(true)->registerAdminRoute( $routes, 'admin-spells-by-realm', diff --git a/backend/app/routes/admin/users.php b/backend/app/routes/admin/users.php index 1374f3452..5929b2a54 100755 --- a/backend/app/routes/admin/users.php +++ b/backend/app/routes/admin/users.php @@ -175,6 +175,21 @@ function (Request $request, array $args) { Permissions::ADMIN_USERS_EDIT, ['POST'] ); + App::getInstance(true)->registerAdminRoute( + $routes, + 'admin-users-verify-email', + '/api/admin/users/{uuid}/verify-email', + function (Request $request, array $args) { + $uuid = $args['uuid'] ?? null; + if (!$uuid || !is_string($uuid)) { + return ApiResponse::error('Missing or invalid UUID', 'INVALID_UUID', 400); + } + + return (new UsersController())->forceVerifyEmail($request, $uuid); + }, + Permissions::ADMIN_USERS_EDIT, + ['POST'] + ); App::getInstance(true)->registerAdminRoute( $routes, 'admin-users-ban', diff --git a/backend/app/routes/user/auth.php b/backend/app/routes/user/auth.php index 9269ab621..c199bfce3 100755 --- a/backend/app/routes/user/auth.php +++ b/backend/app/routes/user/auth.php @@ -96,6 +96,19 @@ function (Request $request) { 'user-auth' ); + // POST (resend verification email) + App::getInstance(true)->registerApiRoute( + $routes, + 'verify-email-resend', + '/api/user/auth/verify-email/resend', + function (Request $request) { + return (new VerifyEmailController())->resend($request); + }, + ['POST'], + Rate::perMinute(3), + 'user-auth-verify-email' + ); + // PUT (reset password) App::getInstance(true)->registerApiRoute( $routes, @@ -241,6 +254,30 @@ function (Request $request) { 'user-auth-oidc' ); + App::getInstance(true)->registerAuthRoute( + $routes, + 'oidc-link', + '/api/user/auth/oidc/link', + function (Request $request) { + return (new OidcController())->link($request); + }, + ['GET'], + Rate::perMinute(5), + 'user-auth-oidc' + ); + + App::getInstance(true)->registerAuthRoute( + $routes, + 'oidc-unlink', + '/api/user/auth/oidc/unlink', + function (Request $request) { + return (new OidcController())->unlink($request); + }, + ['DELETE'], + Rate::perMinute(5), + 'user-auth-oidc' + ); + // Email Login (OTP) routes App::getInstance(true)->registerApiRoute( $routes, diff --git a/backend/app/routes/user/chatbot.php b/backend/app/routes/user/chatbot.php index 9f2fc9242..34880d549 100755 --- a/backend/app/routes/user/chatbot.php +++ b/backend/app/routes/user/chatbot.php @@ -35,6 +35,18 @@ function (Request $request) { 'user-chatbot' ); + App::getInstance(true)->registerAuthRoute( + $routes, + 'user-chatbot-chat-stream', + '/api/user/chatbot/chat/stream', + function (Request $request) { + return (new ChatbotController())->streamChat($request); + }, + ['POST'], + Rate::perMinute(30), + 'user-chatbot' + ); + App::getInstance(true)->registerAuthRoute( $routes, 'chatbot-conversations', diff --git a/backend/app/routes/user/ldap.php b/backend/app/routes/user/ldap.php index 313677123..96c566647 100644 --- a/backend/app/routes/user/ldap.php +++ b/backend/app/routes/user/ldap.php @@ -31,4 +31,24 @@ function (Request $request) { }, ['PUT'] ); + + App::getInstance(true)->registerAuthRoute( + $routes, + 'user-ldap-link', + '/api/user/auth/ldap/link', + function (Request $request) { + return (new LdapController())->link($request); + }, + ['PUT'] + ); + + App::getInstance(true)->registerAuthRoute( + $routes, + 'user-ldap-unlink', + '/api/user/auth/ldap/unlink', + function (Request $request) { + return (new LdapController())->unlink($request); + }, + ['DELETE'] + ); }; diff --git a/backend/app/routes/user/server/core.php b/backend/app/routes/user/server/core.php index d79592db2..dc78a4a04 100755 --- a/backend/app/routes/user/server/core.php +++ b/backend/app/routes/user/server/core.php @@ -104,6 +104,46 @@ function (Request $request, array $args) { 'user-server-update' ); + App::getInstance(true)->registerServerRoute( + $routes, + 'session-server-custom-variable-create', + '/api/user/servers/{uuidShort}/custom-variables', + function (Request $request, array $args) { + $uuidShort = $args['uuidShort'] ?? null; + if (!$uuidShort) { + return ApiResponse::error('Missing or invalid UUID short', 'INVALID_UUID_SHORT', 400); + } + + return (new ServerUserController())->createCustomVariable($request, $uuidShort); + }, + 'uuidShort', + ['POST'], + Rate::perMinute(10), + 'user-server-custom-variable-create' + ); + + App::getInstance(true)->registerServerRoute( + $routes, + 'session-server-custom-variable-delete', + '/api/user/servers/{uuidShort}/custom-variables/{variableId}', + function (Request $request, array $args) { + $uuidShort = $args['uuidShort'] ?? null; + $variableId = $args['variableId'] ?? null; + if (!$uuidShort) { + return ApiResponse::error('Missing or invalid UUID short', 'INVALID_UUID_SHORT', 400); + } + if (!$variableId || !is_numeric($variableId)) { + return ApiResponse::error('Missing or invalid variable ID', 'INVALID_VARIABLE_ID', 400); + } + + return (new ServerUserController())->deleteCustomVariable($request, $uuidShort, (int) $variableId); + }, + 'uuidShort', + ['DELETE'], + Rate::perMinute(10), + 'user-server-custom-variable-delete' + ); + // Rate limit: Admin can override in ratelimit.json, default is 1 per hour (very restrictive for deletion) App::getInstance(true)->registerServerRoute( $routes, diff --git a/backend/app/routes/user/server/files.php b/backend/app/routes/user/server/files.php index c4f592281..7a06a75e0 100755 --- a/backend/app/routes/user/server/files.php +++ b/backend/app/routes/user/server/files.php @@ -42,6 +42,24 @@ function (Request $request, array $args) { 'user-server-files' ); + App::getInstance(true)->registerServerRoute( + $routes, + 'session-server-archive-list', + '/api/user/servers/{uuidShort}/archive-list', + function (Request $request, array $args) { + $uuidShort = $args['uuidShort'] ?? null; + if (!$uuidShort) { + return ApiResponse::error('Missing or invalid UUID short', 'INVALID_UUID_SHORT', 400); + } + + return (new ServerFilesController())->listArchiveDirectory($request, $uuidShort); + }, + 'uuidShort', + ['GET'], + Rate::perMinute(60), + 'user-server-files' + ); + App::getInstance(true)->registerServerRoute( $routes, 'session-server-search-files', @@ -139,6 +157,66 @@ function (Request $request, array $args) { 'user-server-files' ); + App::getInstance(true)->registerServerRoute( + $routes, + 'session-server-trash-list', + '/api/user/servers/{uuidShort}/trash', + function (Request $request, array $args) { + $uuidShort = $args['uuidShort'] ?? null; + + return (new ServerFilesController())->listTrash($request, $uuidShort); + }, + 'uuidShort', + ['GET'], + Rate::perMinute(60), + 'user-server-files' + ); + + App::getInstance(true)->registerServerRoute( + $routes, + 'session-server-trash-restore', + '/api/user/servers/{uuidShort}/trash/restore', + function (Request $request, array $args) { + $uuidShort = $args['uuidShort'] ?? null; + + return (new ServerFilesController())->restoreTrash($request, $uuidShort); + }, + 'uuidShort', + ['POST'], + Rate::perMinute(20), + 'user-server-files' + ); + + App::getInstance(true)->registerServerRoute( + $routes, + 'session-server-trash-delete', + '/api/user/servers/{uuidShort}/trash/delete', + function (Request $request, array $args) { + $uuidShort = $args['uuidShort'] ?? null; + + return (new ServerFilesController())->deleteTrashEntries($request, $uuidShort); + }, + 'uuidShort', + ['POST'], + Rate::perMinute(10), + 'user-server-files' + ); + + App::getInstance(true)->registerServerRoute( + $routes, + 'session-server-trash-empty', + '/api/user/servers/{uuidShort}/trash/empty', + function (Request $request, array $args) { + $uuidShort = $args['uuidShort'] ?? null; + + return (new ServerFilesController())->emptyTrash($request, $uuidShort); + }, + 'uuidShort', + ['POST'], + Rate::perMinute(5), + 'user-server-files' + ); + App::getInstance(true)->registerServerRoute( $routes, 'session-server-wipe-all-files', @@ -212,6 +290,21 @@ function (Request $request, array $args) { 'user-server-files' ); + App::getInstance(true)->registerServerRoute( + $routes, + 'session-server-extract-archive-selection', + '/api/user/servers/{uuidShort}/extract-archive-selection', + function (Request $request, array $args) { + $uuidShort = $args['uuidShort'] ?? null; + + return (new ServerFilesController())->extractArchiveSelection($request, $uuidShort); + }, + 'uuidShort', + ['POST'], + Rate::perMinute(20), + 'user-server-files' + ); + App::getInstance(true)->registerServerRoute( $routes, 'session-server-change-permissions', diff --git a/backend/app/routes/user/session.php b/backend/app/routes/user/session.php index 0fef95101..6846e0cb0 100755 --- a/backend/app/routes/user/session.php +++ b/backend/app/routes/user/session.php @@ -75,6 +75,17 @@ function (Request $request) { Rate::perMinute(30), // Default: Admin can override in ratelimit.json 'user-session' ); + App::getInstance(true)->registerAuthRoute( + $routes, + 'data-request-create', + '/api/user/data-request', + function (Request $request) { + return (new SessionController())->requestDataExport($request); + }, + ['POST'], + Rate::perMinute(2), // Default: Admin can override in ratelimit.json + 'user-session' + ); App::getInstance(true)->registerAuthRoute( $routes, 'mails-get', diff --git a/backend/app/routes/user/spells.php b/backend/app/routes/user/spells.php index 603294c23..f1b4a1c12 100755 --- a/backend/app/routes/user/spells.php +++ b/backend/app/routes/user/spells.php @@ -69,7 +69,17 @@ function (Request $request) { }); } - return ApiResponse::success(['spells' => array_values($spells)], 'Spells fetched successfully', 200); + $spells = array_values($spells); + usort($spells, static function (array $a, array $b): int { + $order = ($a['sort_order'] ?? 0) <=> ($b['sort_order'] ?? 0); + if ($order !== 0) { + return $order; + } + + return strcasecmp($a['name'] ?? '', $b['name'] ?? ''); + }); + + return ApiResponse::success(['spells' => $spells], 'Spells fetched successfully', 200); }, ['GET'], Rate::perMinute(60), // Default: Admin can override in ratelimit.json diff --git a/backend/app/routes/user/vds/chatbot.php b/backend/app/routes/user/vds/chatbot.php index afe6da3b0..dace50110 100644 --- a/backend/app/routes/user/vds/chatbot.php +++ b/backend/app/routes/user/vds/chatbot.php @@ -35,6 +35,18 @@ function (Request $request) { 'user-vds-chatbot' ); + App::getInstance(true)->registerAuthRoute( + $routes, + 'vds-chatbot-chat-stream', + '/api/user/vds-chatbot/chat/stream', + function (Request $request) { + return (new VdsChatbotController())->streamChat($request); + }, + ['POST'], + Rate::perMinute(30), + 'user-vds-chatbot' + ); + App::getInstance(true)->registerAuthRoute( $routes, 'vds-chatbot-conversations', diff --git a/backend/cli b/backend/cli index 1947eb206..994acd67b 100755 --- a/backend/cli +++ b/backend/cli @@ -42,7 +42,7 @@ define('APP_START', microtime(true)); define('APP_DIR', APP_PUBLIC . '/'); define('APP_CRON_DIR', APP_PUBLIC . '/storage/cron/'); define('SYSTEM_KERNEL_NAME', php_uname('s')); -define('APP_VERSION', 'v1.3.7.2'); +define('APP_VERSION', 'v1.3.7.3'); define('APP_UPSTREAM', 'stable'); define('REQUEST_ID', uniqid()); define('TELEMETRY', true); diff --git a/backend/composer.lock b/backend/composer.lock index 15e18f21e..01aa2392c 100755 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -232,16 +232,16 @@ }, { "name": "dg/mysql-dump", - "version": "v1.6.0", + "version": "v1.7.0", "source": { "type": "git", "url": "https://github.com/dg/MySQL-dump.git", - "reference": "b83859026dc3651c6aa39376705fbfa57c0486c5" + "reference": "257d0c2c0ed4415330912456d252f2f4253496cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dg/MySQL-dump/zipball/b83859026dc3651c6aa39376705fbfa57c0486c5", - "reference": "b83859026dc3651c6aa39376705fbfa57c0486c5", + "url": "https://api.github.com/repos/dg/MySQL-dump/zipball/257d0c2c0ed4415330912456d252f2f4253496cc", + "reference": "257d0c2c0ed4415330912456d252f2f4253496cc", "shasum": "" }, "require": { @@ -269,9 +269,9 @@ "mysql" ], "support": { - "source": "https://github.com/dg/MySQL-dump/tree/v1.6.0" + "source": "https://github.com/dg/MySQL-dump/tree/v1.7.0" }, - "time": "2024-09-16T04:30:48+00:00" + "time": "2026-05-18T00:49:32+00:00" }, { "name": "doctrine/deprecations", @@ -507,16 +507,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.0", + "version": "7.10.2", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + "reference": "aed36fd5fb4844f284252a999d9abf35d3a9a1ae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aed36fd5fb4844f284252a999d9abf35d3a9a1ae", + "reference": "aed36fd5fb4844f284252a999d9abf35d3a9a1ae", "shasum": "" }, "require": { @@ -534,8 +534,9 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.3.2", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -613,7 +614,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + "source": "https://github.com/guzzle/guzzle/tree/7.10.2" }, "funding": [ { @@ -629,20 +630,20 @@ "type": "tidelift" } ], - "time": "2025-08-23T22:36:01+00:00" + "time": "2026-05-20T11:58:52+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.3.0", + "version": "2.3.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "481557b130ef3790cf82b713667b43030dc9c957" + "reference": "d2d8dfae4757f384d630fdffc2d8d6618d8f4c5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", - "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "url": "https://api.github.com/repos/guzzle/promises/zipball/d2d8dfae4757f384d630fdffc2d8d6618d8f4c5e", + "reference": "d2d8dfae4757f384d630fdffc2d8d6618d8f4c5e", "shasum": "" }, "require": { @@ -650,7 +651,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "type": "library", "extra": { @@ -696,7 +697,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.0" + "source": "https://github.com/guzzle/promises/tree/2.3.1" }, "funding": [ { @@ -712,20 +713,20 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:34:08+00:00" + "time": "2026-05-19T18:30:48+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.9.0", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + "reference": "73ab136360b5dfd858006eae9795e8fe43c80361" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361", + "reference": "73ab136360b5dfd858006eae9795e8fe43c80361", "shasum": "" }, "require": { @@ -740,9 +741,9 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", + "http-interop/http-factory-tests": "1.1.0", "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -813,7 +814,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.9.0" + "source": "https://github.com/guzzle/psr7/tree/2.10.1" }, "funding": [ { @@ -829,7 +830,7 @@ "type": "tidelift" } ], - "time": "2026-03-10T16:41:02+00:00" + "time": "2026-05-20T09:27:36+00:00" }, { "name": "ifsnop/mysqldump-php", @@ -2564,16 +2565,16 @@ }, { "name": "symfony/mime", - "version": "v7.4.9", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "2d550c4758ba4c47519a6667c36553d535705b0c" + "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/2d550c4758ba4c47519a6667c36553d535705b0c", - "reference": "2d550c4758ba4c47519a6667c36553d535705b0c", + "url": "https://api.github.com/repos/symfony/mime/zipball/b198dd66c211c97119bcaaff7c13431dbbb5e470", + "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470", "shasum": "" }, "require": { @@ -2629,7 +2630,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.9" + "source": "https://github.com/symfony/mime/tree/v7.4.12" }, "funding": [ { @@ -2649,7 +2650,7 @@ "type": "tidelift" } ], - "time": "2026-04-29T13:21:53+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/polyfill-ctype", @@ -3474,16 +3475,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.9", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "287771d8bc86eacb30678dd10eda6c64a859951f" + "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/287771d8bc86eacb30678dd10eda6c64a859951f", - "reference": "287771d8bc86eacb30678dd10eda6c64a859951f", + "url": "https://api.github.com/repos/symfony/routing/zipball/3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", + "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", "shasum": "" }, "require": { @@ -3535,7 +3536,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.9" + "source": "https://github.com/symfony/routing/tree/v7.4.12" }, "funding": [ { @@ -3555,7 +3556,7 @@ "type": "tidelift" } ], - "time": "2026-04-22T15:21:55+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/serializer", @@ -3907,16 +3908,16 @@ }, { "name": "symfony/yaml", - "version": "v7.4.11", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "e2eb64a57763815ccae07ac1c7653d6cc1c326fd" + "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/e2eb64a57763815ccae07ac1c7653d6cc1c326fd", - "reference": "e2eb64a57763815ccae07ac1c7653d6cc1c326fd", + "url": "https://api.github.com/repos/symfony/yaml/zipball/8b6952b56ca6417f25f7a65758cadd0ce02edc51", + "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51", "shasum": "" }, "require": { @@ -3959,7 +3960,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.11" + "source": "https://github.com/symfony/yaml/tree/v7.4.12" }, "funding": [ { @@ -3979,7 +3980,7 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:04:42+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "vlucas/phpdotenv", @@ -4138,16 +4139,16 @@ }, { "name": "web-auth/webauthn-lib", - "version": "5.3.2", + "version": "5.3.3", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "a272f254c056fb3d6c80a4801d3c7c5fedc6a08d" + "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/a272f254c056fb3d6c80a4801d3c7c5fedc6a08d", - "reference": "a272f254c056fb3d6c80a4801d3c7c5fedc6a08d", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/e6f656d6c6b29fa305382fe6a0a3be8177d177df", + "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df", "shasum": "" }, "require": { @@ -4208,7 +4209,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.2" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.3" }, "funding": [ { @@ -4220,20 +4221,20 @@ "type": "patreon" } ], - "time": "2026-05-01T12:14:37+00:00" + "time": "2026-05-17T19:04:30+00:00" }, { "name": "webmozart/assert", - "version": "2.3.0", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", - "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", "shasum": "" }, "require": { @@ -4249,7 +4250,11 @@ }, "type": "library", "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, "branch-alias": { + "dev-master": "2.0-dev", "dev-feature/2-0": "2.0-dev" } }, @@ -4280,9 +4285,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.3.0" + "source": "https://github.com/webmozarts/assert/tree/2.4.0" }, - "time": "2026-04-11T10:33:05+00:00" + "time": "2026-05-20T13:07:01+00:00" }, { "name": "zircote/swagger-php", @@ -4845,16 +4850,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.1", + "version": "v3.95.2", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065" + "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a9727678fbd12997f1d9de8f4a37824ed9df1065", - "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a28d88a5e172b27e78d0816992b15a9df3da20f1", + "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1", "shasum": "" }, "require": { @@ -4886,8 +4891,8 @@ "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.8.0", - "infection/infection": "^0.32.6", + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", "justinrainbow/json-schema": "^6.8.0", "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", @@ -4938,7 +4943,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.1" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.2" }, "funding": [ { @@ -4946,7 +4951,7 @@ "type": "github" } ], - "time": "2026-04-12T17:00:09+00:00" + "time": "2026-05-15T09:20:44+00:00" }, { "name": "myclabs/deep-copy", @@ -5128,11 +5133,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.54", + "version": "2.1.55", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", - "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566", + "reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566", "shasum": "" }, "require": { @@ -5177,7 +5182,7 @@ "type": "github" } ], - "time": "2026-04-29T13:31:09+00:00" + "time": "2026-05-18T11:57:34+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/backend/public/index.php b/backend/public/index.php index b52abbbc4..a7fdbe31b 100755 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -34,7 +34,7 @@ define('SYSTEM_OS_NAME', gethostname() . '/' . PHP_OS_FAMILY); define('SYSTEM_KERNEL_NAME', php_uname('s')); define('TELEMETRY', true); -define('APP_VERSION', 'v1.3.7.2'); +define('APP_VERSION', 'v1.3.7.3'); define('APP_UPSTREAM', 'stable'); define('REQUEST_ID', uniqid()); diff --git a/backend/storage/cron/php/AServerScheduleProcessor.php b/backend/storage/cron/php/AServerScheduleProcessor.php index 3eb60709c..0ed400f7c 100755 --- a/backend/storage/cron/php/AServerScheduleProcessor.php +++ b/backend/storage/cron/php/AServerScheduleProcessor.php @@ -133,14 +133,17 @@ private function processSchedule(array $schedule) if (!$this->isServerOnline($server)) { MinecraftColorCodeSupport::sendOutputWithNewLine('&eServer is offline, skipping schedule: ' . $schedule['name']); - // Calculate next run time and update schedule + // Calculate next run time and update schedule, honouring the + // schedule's authoring timezone so e.g. "0 3 * * *" still means + // 03:00 local even after PHP's default zone was pinned to UTC. $nextRunAt = ServerSchedule::calculateNextRunTime( $schedule['cron_day_of_week'], $schedule['cron_month'], $schedule['cron_day_of_month'], $schedule['cron_hour'], $schedule['cron_minute'], - $schedule['next_run_at'] ?? null + $schedule['next_run_at'] ?? null, + $schedule['timezone'] ?? 'UTC' ); ServerSchedule::updateSchedule($schedule['id'], [ @@ -168,21 +171,24 @@ private function processSchedule(array $schedule) // Execute the schedule $this->executeSchedule($schedule, $server); - // Calculate next run time + // Calculate next run time in the schedule's authoring timezone. $nextRunAt = ServerSchedule::calculateNextRunTime( $schedule['cron_day_of_week'], $schedule['cron_month'], $schedule['cron_day_of_month'], $schedule['cron_hour'], $schedule['cron_minute'], - $schedule['next_run_at'] ?? null + $schedule['next_run_at'] ?? null, + $schedule['timezone'] ?? 'UTC' ); - // Update schedule with next run time and mark as not processing + // Update schedule with next run time and mark as not processing. + // `last_run_at` is a TIMESTAMP and PHP runs in UTC, so gmdate is + // explicit about intent. ServerSchedule::updateSchedule($schedule['id'], [ 'is_processing' => 0, 'next_run_at' => $nextRunAt, - 'last_run_at' => date('Y-m-d H:i:s'), + 'last_run_at' => gmdate('Y-m-d H:i:s'), ]); MinecraftColorCodeSupport::sendOutputWithNewLine('&aSchedule processed successfully: ' . $schedule['name'] . ' (Next run: ' . $nextRunAt . ')'); diff --git a/backend/storage/cron/php/UserDataExportProcessor.php b/backend/storage/cron/php/UserDataExportProcessor.php new file mode 100644 index 000000000..1f301fe76 --- /dev/null +++ b/backend/storage/cron/php/UserDataExportProcessor.php @@ -0,0 +1,319 @@ +. + */ + +namespace App\Cron; + +use App\App; +use App\Chat\Node; +use App\Chat\Backup; +use App\Chat\Ticket; +use App\Chat\Database; +use App\Chat\TimedTask; +use App\Chat\TicketMessage; +use App\Chat\UserDataExport; +use App\Services\Wings\Wings; +use App\Chat\TicketAttachment; +use App\Config\ConfigInterface; +use App\Cli\Utils\MinecraftColorCodeSupport; +use App\Services\UserDataExport\UserDataExportService; + +/** + * Processes queued user data export requests. + */ +class UserDataExportProcessor implements TimeTask +{ + private const TASK_NAME = 'user-data-export-processor'; + private const MAX_EXPORTS_PER_RUN = 3; + private const MAX_ATTEMPTS = 3; + private const EXPORT_RETENTION_HOURS = 24; + private const CLEANUP_LIMIT = 25; + + /** + * Entry point for the cron job. + */ + public function run() + { + $cron = new Cron(self::TASK_NAME, '1M'); + $force = true; + + try { + $ran = $cron->runIfDue(function () { + $cleaned = $this->cleanupExpiredExports(); + $processed = $this->processExports(); + TimedTask::markRun( + self::TASK_NAME, + true, + 'Processed ' . $processed . ' user data export(s), cleaned ' . $cleaned . ' expired export(s)' + ); + }, $force); + + if (!$ran) { + return; + } + } catch (\Exception $e) { + App::getInstance(false, true)->getLogger()->error('Failed to process user data exports: ' . $e->getMessage()); + TimedTask::markRun(self::TASK_NAME, false, $e->getMessage()); + } + } + + private function processExports(): int + { + $processed = 0; + $service = new UserDataExportService(); + + MinecraftColorCodeSupport::sendOutputWithNewLine('&aProcessing user data exports...'); + + for ($i = 0; $i < self::MAX_EXPORTS_PER_RUN; ++$i) { + $export = UserDataExport::claimNextPending(self::MAX_ATTEMPTS); + if ($export === null) { + break; + } + + try { + $this->processExport($service, $export); + ++$processed; + MinecraftColorCodeSupport::sendOutputWithNewLine('&aProcessed user data export: ' . $export['uuid']); + } catch (\Throwable $e) { + UserDataExport::markFailed((int) $export['id'], $e->getMessage()); + App::getInstance(false, true)->getLogger()->error('User data export failed (' . $export['uuid'] . '): ' . $e->getMessage()); + MinecraftColorCodeSupport::sendOutputWithNewLine('&cFailed user data export: ' . $e->getMessage()); + } + } + + return $processed; + } + + private function cleanupExpiredExports(): int + { + $cleaned = 0; + $exports = UserDataExport::getExpiredForCleanup(self::EXPORT_RETENTION_HOURS, self::CLEANUP_LIMIT); + if (empty($exports)) { + return 0; + } + + MinecraftColorCodeSupport::sendOutputWithNewLine('&aCleaning expired user data exports...'); + + foreach ($exports as $export) { + try { + $this->cleanupExport($export); + ++$cleaned; + } catch (\Throwable $e) { + App::getInstance(false, true)->getLogger()->warning( + 'Failed to clean expired user data export ' . ($export['uuid'] ?? 'unknown') . ': ' . $e->getMessage() + ); + } + } + + return $cleaned; + } + + private function cleanupExport(array $export): void + { + $ticketId = (int) ($export['ticket_id'] ?? 0); + if ($ticketId > 0) { + $this->deleteTicketAttachmentFiles($ticketId); + } + + if (!empty($export['file_path']) && is_string($export['file_path'])) { + $this->deleteAttachmentPath($export['file_path']); + } + + $this->cleanupWingsExportBackups((string) ($export['uuid'] ?? '')); + + if ($ticketId > 0 && Ticket::getById($ticketId) !== null) { + Ticket::delete($ticketId); + } else { + UserDataExport::deleteById((int) $export['id']); + } + } + + private function deleteTicketAttachmentFiles(int $ticketId): void + { + foreach (TicketAttachment::getAll($ticketId, null, 1000, 0) as $attachment) { + if (isset($attachment['file_path']) && is_string($attachment['file_path'])) { + $this->deleteAttachmentPath($attachment['file_path']); + } + } + } + + private function deleteAttachmentPath(string $filePath): void + { + $resolvedPath = $this->resolveAttachmentPath($filePath); + if ($resolvedPath !== null && is_file($resolvedPath) && !@unlink($resolvedPath)) { + App::getInstance(false, true)->getLogger()->warning('Failed to delete user data export attachment: ' . $resolvedPath); + } + } + + private function resolveAttachmentPath(string $filePath): ?string + { + $normalized = ltrim($filePath, '/\\'); + if (strpos($normalized, 'attachments/') !== 0 || strpos($normalized, '..') !== false) { + return null; + } + + $relative = substr($normalized, strlen('attachments/')); + if ($relative === '') { + return null; + } + + $attachmentsDir = $this->getAttachmentsDirectory(); + $realAttachmentsDir = realpath($attachmentsDir); + if ($realAttachmentsDir === false) { + return null; + } + + $realPath = realpath($attachmentsDir . '/' . $relative); + if ($realPath === false) { + return null; + } + + $realAttachmentsDir = rtrim($realAttachmentsDir, '/\\') . DIRECTORY_SEPARATOR; + if (strpos($realPath, $realAttachmentsDir) !== 0) { + return null; + } + + return $realPath; + } + + private function cleanupWingsExportBackups(string $exportUuid): void + { + if (!preg_match('/^[a-f0-9\-]{36}$/i', $exportUuid)) { + return; + } + + foreach ($this->getExportBackups($exportUuid) as $backup) { + try { + $node = Node::getNodeById((int) $backup['node_id']); + if ($node !== null && !empty($backup['server_uuid']) && !empty($backup['uuid'])) { + $wings = new Wings( + $node['fqdn'], + (int) $node['daemonListen'], + $node['scheme'], + $node['daemon_token'], + 30 + ); + $response = $wings->getServer()->deleteBackup((string) $backup['server_uuid'], (string) $backup['uuid']); + if (!$response->isSuccessful()) { + App::getInstance(false, true)->getLogger()->warning( + 'Failed to delete user data export Wings backup ' . $backup['uuid'] . ': ' . $response->getError() + ); + } + } + } catch (\Throwable $e) { + App::getInstance(false, true)->getLogger()->warning( + 'Failed to delete user data export Wings backup ' . ($backup['uuid'] ?? 'unknown') . ': ' . $e->getMessage() + ); + } + + Backup::deleteBackup((int) $backup['id']); + } + } + + private function getExportBackups(string $exportUuid): array + { + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare( + 'SELECT backups.*, servers.uuid AS server_uuid, servers.node_id + FROM featherpanel_server_backups backups + INNER JOIN featherpanel_servers servers ON servers.id = backups.server_id + WHERE backups.name = :name + AND backups.deleted_at IS NULL' + ); + $stmt->execute(['name' => 'Personal data export backup ' . $exportUuid]); + + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + private function processExport(UserDataExportService $service, array $export): void + { + $ticket = Ticket::getById((int) $export['ticket_id']); + if ($ticket === null) { + throw new \RuntimeException('Ticket not found for export'); + } + + $result = $service->buildExport($export); + $exportDir = $result['export_dir']; + + try { + $filename = $this->buildAttachmentFilename((string) $ticket['uuid'], (string) $export['uuid']); + $attachmentDir = $this->getAttachmentsDirectory(); + if (!is_dir($attachmentDir) && !mkdir($attachmentDir, 0755, true) && !is_dir($attachmentDir)) { + throw new \RuntimeException('Failed to create attachments directory'); + } + + $zipPath = $attachmentDir . '/' . $filename; + $service->zipExportDirectory($exportDir, $zipPath); + + $messageId = TicketMessage::create([ + 'ticket_id' => (int) $ticket['id'], + 'user_uuid' => null, + 'message' => $this->buildSystemReplyMessage($filename), + 'is_internal' => false, + ]); + + if (!$messageId) { + throw new \RuntimeException('Failed to create system reply for export ticket'); + } + + $attachmentId = TicketAttachment::create([ + 'ticket_id' => (int) $ticket['id'], + 'message_id' => $messageId, + 'file_name' => $filename, + 'file_path' => '/attachments/' . $filename, + 'file_size' => filesize($zipPath) ?: 0, + 'file_type' => 'application/zip', + 'user_downloadable' => 1, + ]); + + if (!$attachmentId) { + @unlink($zipPath); + throw new \RuntimeException('Failed to create ticket attachment for export'); + } + + UserDataExport::markCompleted((int) $export['id'], '/attachments/' . $filename); + } finally { + $service->removeDirectory($exportDir); + } + } + + private function buildSystemReplyMessage(string $filename): string + { + $appName = App::getInstance(false, true)->getConfig()->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'); + + return implode("\n", [ + 'Your personal data export has been generated automatically by ' . $appName . '.', + '', + 'The export is attached to this ticket as `' . $filename . '`.', + 'Sensitive credentials and tokens are represented as metadata only and raw secret values are not included.', + ]); + } + + private function buildAttachmentFilename(string $ticketUuid, string $exportUuid): string + { + $safeTicketUuid = preg_replace('/[^a-zA-Z0-9._-]/', '_', $ticketUuid) ?: 'ticket'; + $safeExportUuid = preg_replace('/[^a-zA-Z0-9._-]/', '_', $exportUuid) ?: bin2hex(random_bytes(8)); + + return $safeTicketUuid . '_data_export_' . $safeExportUuid . '.zip'; + } + + private function getAttachmentsDirectory(): string + { + $appDir = defined('APP_DIR') ? rtrim((string) APP_DIR, '/') : dirname(__DIR__, 3); + + return $appDir . '/public/attachments'; + } +} diff --git a/backend/storage/cron/php/ZCheckExpiredServers.php b/backend/storage/cron/php/ZCheckExpiredServers.php index 2da50329e..46a286843 100644 --- a/backend/storage/cron/php/ZCheckExpiredServers.php +++ b/backend/storage/cron/php/ZCheckExpiredServers.php @@ -29,6 +29,7 @@ use App\Chat\Server; use App\Chat\Database; use App\Chat\VmInstance; +use App\Helpers\ModerationReasonHelper; use App\Cli\Utils\MinecraftColorCodeSupport; class ZCheckExpiredServers implements TimeTask @@ -161,7 +162,11 @@ private function checkExpiredVmInstances(): int foreach ($expiredVms as $vm) { try { // Suspend the VM instance - $success = VmInstance::update((int) $vm['id'], ['suspended' => 1]); + $reason = ModerationReasonHelper::formatReason('payment_overdue', 'Service expired on ' . ($vm['expires_at'] ?? '')); + $success = VmInstance::update((int) $vm['id'], ModerationReasonHelper::suspensionAppliedFields($reason, [ + 'uuid' => 'system', + 'username' => 'System', + ])); if ($success) { ++$suspendedCount; diff --git a/backend/storage/cron/runner.php b/backend/storage/cron/runner.php index 53c136f82..9a046341a 100755 --- a/backend/storage/cron/runner.php +++ b/backend/storage/cron/runner.php @@ -16,7 +16,6 @@ */ use App\Plugins\PluginManager; -use App\Config\ConfigInterface; define('APP_STARTUP', microtime(true)); define('APP_START', microtime(true)); @@ -58,13 +57,15 @@ App::sendOutputWithNewLine('&7Starting App cron runner.'); /** - * Ensure the correct timezone is set for the cron runner. + * Force PHP's default timezone to UTC for the cron runner. + * + * Cron jobs use `date('Y-m-d H:i:s')` (and friends) to generate timestamps + * that are stored in DATETIME columns — those columns are interpreted as + * UTC by the frontend, so the cron context must also be UTC, regardless of + * the admin-configured `app_timezone` panel setting (which is a display + * preference, not a storage policy). */ -$timezone = $app->getConfig()->getSetting(ConfigInterface::APP_TIMEZONE, 'UTC'); -if (!@date_default_timezone_set($timezone)) { - $app->getLogger()->warning("Invalid timezone '$timezone', falling back to UTC."); - date_default_timezone_set('UTC'); -} +date_default_timezone_set('UTC'); // Run main cronjobs foreach (glob(__DIR__ . '/php/*.php') as $file) { diff --git a/backend/storage/migrations/2026-05-17.16.22-add-chatbot-message-usage-metadata.sql b/backend/storage/migrations/2026-05-17.16.22-add-chatbot-message-usage-metadata.sql new file mode 100644 index 000000000..d8576665c --- /dev/null +++ b/backend/storage/migrations/2026-05-17.16.22-add-chatbot-message-usage-metadata.sql @@ -0,0 +1,7 @@ +ALTER TABLE `featherpanel_chatbot_messages` +ADD COLUMN `input_tokens` INT UNSIGNED DEFAULT NULL AFTER `model`, +ADD COLUMN `output_tokens` INT UNSIGNED DEFAULT NULL AFTER `input_tokens`, +ADD COLUMN `total_tokens` INT UNSIGNED DEFAULT NULL AFTER `output_tokens`, +ADD COLUMN `token_source` VARCHAR(32) DEFAULT NULL AFTER `total_tokens`, +ADD COLUMN `tool_activity` JSON DEFAULT NULL AFTER `token_source`, +ADD COLUMN `usage_json` JSON DEFAULT NULL AFTER `tool_activity`; diff --git a/backend/storage/migrations/2026-05-17.16.30-add-chatbot-context-summary.sql b/backend/storage/migrations/2026-05-17.16.30-add-chatbot-context-summary.sql new file mode 100644 index 000000000..8ac596e33 --- /dev/null +++ b/backend/storage/migrations/2026-05-17.16.30-add-chatbot-context-summary.sql @@ -0,0 +1,3 @@ +ALTER TABLE `featherpanel_chatbot_conversations` +ADD COLUMN `context_summary` TEXT DEFAULT NULL AFTER `memory`, +ADD COLUMN `context_summary_updated_at` TIMESTAMP NULL DEFAULT NULL AFTER `context_summary`; diff --git a/backend/storage/migrations/2026-05-17.19.12-create-server-custom-variables.sql b/backend/storage/migrations/2026-05-17.19.12-create-server-custom-variables.sql new file mode 100644 index 000000000..c0952a253 --- /dev/null +++ b/backend/storage/migrations/2026-05-17.19.12-create-server-custom-variables.sql @@ -0,0 +1,18 @@ +CREATE TABLE + IF NOT EXISTS `featherpanel_server_custom_variables` ( + `id` int (11) NOT NULL AUTO_INCREMENT, + `server_id` int (11) NOT NULL, + `user_id` int (11) NOT NULL, + `name` varchar(191) NOT NULL, + `env_variable` varchar(191) NOT NULL, + `variable_value` text NOT NULL, + `is_encrypted` tinyint (1) NOT NULL DEFAULT 0, + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `server_custom_variables_server_env_unique` (`server_id`, `env_variable`), + KEY `server_custom_variables_server_id_foreign` (`server_id`), + KEY `server_custom_variables_user_id_foreign` (`user_id`), + CONSTRAINT `server_custom_variables_server_id_foreign` FOREIGN KEY (`server_id`) REFERENCES `featherpanel_servers` (`id`) ON DELETE CASCADE, + CONSTRAINT `server_custom_variables_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `featherpanel_users` (`id`) ON DELETE CASCADE + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci; diff --git a/backend/storage/migrations/2026-05-17.20.41-add-encryption-flag-to-server-custom-variables.sql b/backend/storage/migrations/2026-05-17.20.41-add-encryption-flag-to-server-custom-variables.sql new file mode 100644 index 000000000..c2cd7524d --- /dev/null +++ b/backend/storage/migrations/2026-05-17.20.41-add-encryption-flag-to-server-custom-variables.sql @@ -0,0 +1,17 @@ +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'featherpanel_server_custom_variables' + AND COLUMN_NAME = 'is_encrypted' +); + +SET @sql = IF( + @column_exists = 0, + 'ALTER TABLE `featherpanel_server_custom_variables` ADD COLUMN `is_encrypted` tinyint (1) NOT NULL DEFAULT 0 AFTER `variable_value`', + 'SELECT 1' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/storage/migrations/2026-05-17.21.13-create-user-data-exports.sql b/backend/storage/migrations/2026-05-17.21.13-create-user-data-exports.sql new file mode 100644 index 000000000..fc927d560 --- /dev/null +++ b/backend/storage/migrations/2026-05-17.21.13-create-user-data-exports.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS `featherpanel_user_data_exports` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `uuid` CHAR(36) NOT NULL, + `user_uuid` CHAR(36) NOT NULL, + `ticket_id` INT(11) NOT NULL, + `status` ENUM('pending', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'pending', + `attempts` INT(11) NOT NULL DEFAULT 0, + `file_path` VARCHAR(255) DEFAULT NULL, + `error_message` TEXT DEFAULT NULL, + `requested_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `processing_started_at` TIMESTAMP NULL DEFAULT NULL, + `processed_at` TIMESTAMP NULL DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `user_data_exports_uuid_unique` (`uuid`), + KEY `user_data_exports_user_uuid_index` (`user_uuid`), + KEY `user_data_exports_ticket_id_index` (`ticket_id`), + KEY `user_data_exports_status_index` (`status`), + KEY `user_data_exports_requested_at_index` (`requested_at`), + CONSTRAINT `user_data_exports_user_uuid_foreign` + FOREIGN KEY (`user_uuid`) + REFERENCES `featherpanel_users` (`uuid`) + ON DELETE CASCADE, + CONSTRAINT `user_data_exports_ticket_id_foreign` + FOREIGN KEY (`ticket_id`) + REFERENCES `featherpanel_tickets` (`id`) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/backend/storage/migrations/2026-05-17.23.30-shift-legacy-paris-datetimes-to-utc.sql b/backend/storage/migrations/2026-05-17.23.30-shift-legacy-paris-datetimes-to-utc.sql new file mode 100644 index 000000000..edc44c81e --- /dev/null +++ b/backend/storage/migrations/2026-05-17.23.30-shift-legacy-paris-datetimes-to-utc.sql @@ -0,0 +1,286 @@ + +-- Pin the session to UTC so any incidental `CURRENT_TIMESTAMP` (e.g. the +-- featherpanel_migrations bookkeeping row) is at least in UTC. +SET time_zone = '+00:00'; + +SET @migration_cutoff_utc = '2026-05-17 12:00:00'; + +-- ----------------------------------------------------------------------------- +-- featherpanel_users — first_seen + last_seen +-- ----------------------------------------------------------------------------- +SET @cnt := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'featherpanel_users' + AND COLUMN_NAME IN ('first_seen', 'last_seen') +); +SET @sql := IF(@cnt = 2, + "UPDATE `featherpanel_users` + SET `first_seen` = CONVERT_TZ(`first_seen`, 'SYSTEM', '+00:00'), + `last_seen` = CONVERT_TZ(`last_seen`, 'SYSTEM', '+00:00') + WHERE (`first_seen` IS NOT NULL AND `first_seen` < @migration_cutoff_utc) + OR (`last_seen` IS NOT NULL AND `last_seen` < @migration_cutoff_utc)", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- featherpanel_activity — created_at + updated_at +-- ----------------------------------------------------------------------------- +SET @cnt := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'featherpanel_activity' + AND COLUMN_NAME IN ('created_at', 'updated_at') +); +SET @sql := IF(@cnt = 2, + "UPDATE `featherpanel_activity` + SET `created_at` = CONVERT_TZ(`created_at`, 'SYSTEM', '+00:00'), + `updated_at` = CONVERT_TZ(`updated_at`, 'SYSTEM', '+00:00') + WHERE (`created_at` IS NOT NULL AND `created_at` < @migration_cutoff_utc) + OR (`updated_at` IS NOT NULL AND `updated_at` < @migration_cutoff_utc)", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- featherpanel_locations — created_at + updated_at +-- ----------------------------------------------------------------------------- +SET @cnt := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'featherpanel_locations' + AND COLUMN_NAME IN ('created_at', 'updated_at') +); +SET @sql := IF(@cnt = 2, + "UPDATE `featherpanel_locations` + SET `created_at` = CONVERT_TZ(`created_at`, 'SYSTEM', '+00:00'), + `updated_at` = CONVERT_TZ(`updated_at`, 'SYSTEM', '+00:00') + WHERE (`created_at` IS NOT NULL AND `created_at` < @migration_cutoff_utc) + OR (`updated_at` IS NOT NULL AND `updated_at` < @migration_cutoff_utc)", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- featherpanel_installed_plugins.installed_at (single column) +-- ----------------------------------------------------------------------------- +SET @cnt := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'featherpanel_installed_plugins' + AND COLUMN_NAME = 'installed_at' +); +SET @sql := IF(@cnt = 1, + "UPDATE `featherpanel_installed_plugins` + SET `installed_at` = CONVERT_TZ(`installed_at`, 'SYSTEM', '+00:00') + WHERE `installed_at` IS NOT NULL AND `installed_at` < @migration_cutoff_utc", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- featherpanel_addons.date +-- ----------------------------------------------------------------------------- +SET @cnt := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'featherpanel_addons' + AND COLUMN_NAME = 'date' +); +SET @sql := IF(@cnt = 1, + "UPDATE `featherpanel_addons` + SET `date` = CONVERT_TZ(`date`, 'SYSTEM', '+00:00') + WHERE `date` IS NOT NULL AND `date` < @migration_cutoff_utc", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- featherpanel_addons_settings.date +-- ----------------------------------------------------------------------------- +SET @cnt := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'featherpanel_addons_settings' + AND COLUMN_NAME = 'date' +); +SET @sql := IF(@cnt = 1, + "UPDATE `featherpanel_addons_settings` + SET `date` = CONVERT_TZ(`date`, 'SYSTEM', '+00:00') + WHERE `date` IS NOT NULL AND `date` < @migration_cutoff_utc", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- featherpanel_settings.date +-- ----------------------------------------------------------------------------- +SET @cnt := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'featherpanel_settings' + AND COLUMN_NAME = 'date' +); +SET @sql := IF(@cnt = 1, + "UPDATE `featherpanel_settings` + SET `date` = CONVERT_TZ(`date`, 'SYSTEM', '+00:00') + WHERE `date` IS NOT NULL AND `date` < @migration_cutoff_utc", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- Optional plugin: featherpanel_billingoxapay_logs.created_at +-- ----------------------------------------------------------------------------- +SET @cnt := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'featherpanel_billingoxapay_logs' + AND COLUMN_NAME = 'created_at' +); +SET @sql := IF(@cnt = 1, + "UPDATE `featherpanel_billingoxapay_logs` + SET `created_at` = CONVERT_TZ(`created_at`, 'SYSTEM', '+00:00') + WHERE `created_at` IS NOT NULL AND `created_at` < @migration_cutoff_utc", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- Optional plugin: featherpanel_billingrazorpay_logs.created_at +-- ----------------------------------------------------------------------------- +SET @cnt := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'featherpanel_billingrazorpay_logs' + AND COLUMN_NAME = 'created_at' +); +SET @sql := IF(@cnt = 1, + "UPDATE `featherpanel_billingrazorpay_logs` + SET `created_at` = CONVERT_TZ(`created_at`, 'SYSTEM', '+00:00') + WHERE `created_at` IS NOT NULL AND `created_at` < @migration_cutoff_utc", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- Optional plugin: pterodactyl-panel-api API key timestamps +-- ----------------------------------------------------------------------------- +SET @cnt := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'featherpanel_pterodactylpanelapi_pterodactyl_api_key' + AND COLUMN_NAME IN ('created_at', 'updated_at') +); +SET @sql := IF(@cnt = 2, + "UPDATE `featherpanel_pterodactylpanelapi_pterodactyl_api_key` + SET `created_at` = CONVERT_TZ(`created_at`, 'SYSTEM', '+00:00'), + `updated_at` = CONVERT_TZ(`updated_at`, 'SYSTEM', '+00:00') + WHERE (`created_at` IS NOT NULL AND `created_at` < @migration_cutoff_utc) + OR (`updated_at` IS NOT NULL AND `updated_at` < @migration_cutoff_utc)", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- Optional plugin: minecraftplayermanager_logs.created_at +-- ----------------------------------------------------------------------------- +SET @cnt := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'featherpanel_minecraftplayermanager_logs' + AND COLUMN_NAME = 'created_at' +); +SET @sql := IF(@cnt = 1, + "UPDATE `featherpanel_minecraftplayermanager_logs` + SET `created_at` = CONVERT_TZ(`created_at`, 'SYSTEM', '+00:00') + WHERE `created_at` IS NOT NULL AND `created_at` < @migration_cutoff_utc", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- Optional plugin suite: VM / VDS DATETIME defaults +-- ----------------------------------------------------------------------------- + +-- vm_creation_pending.created_at +SET @cnt := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'featherpanel_vm_creation_pending' AND COLUMN_NAME = 'created_at'); +SET @sql := IF(@cnt = 1, + "UPDATE `featherpanel_vm_creation_pending` + SET `created_at` = CONVERT_TZ(`created_at`, 'SYSTEM', '+00:00') + WHERE `created_at` IS NOT NULL AND `created_at` < @migration_cutoff_utc", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- vm_instances.created_at + updated_at +SET @cnt := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'featherpanel_vm_instances' AND COLUMN_NAME IN ('created_at','updated_at')); +SET @sql := IF(@cnt = 2, + "UPDATE `featherpanel_vm_instances` + SET `created_at` = CONVERT_TZ(`created_at`, 'SYSTEM', '+00:00'), + `updated_at` = CONVERT_TZ(`updated_at`, 'SYSTEM', '+00:00') + WHERE (`created_at` IS NOT NULL AND `created_at` < @migration_cutoff_utc) + OR (`updated_at` IS NOT NULL AND `updated_at` < @migration_cutoff_utc)", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- vm_instance_ips.created_at + updated_at +SET @cnt := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'featherpanel_vm_instance_ips' AND COLUMN_NAME IN ('created_at','updated_at')); +SET @sql := IF(@cnt = 2, + "UPDATE `featherpanel_vm_instance_ips` + SET `created_at` = CONVERT_TZ(`created_at`, 'SYSTEM', '+00:00'), + `updated_at` = CONVERT_TZ(`updated_at`, 'SYSTEM', '+00:00') + WHERE (`created_at` IS NOT NULL AND `created_at` < @migration_cutoff_utc) + OR (`updated_at` IS NOT NULL AND `updated_at` < @migration_cutoff_utc)", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- vm_logs.created_at +SET @cnt := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'featherpanel_vm_logs' AND COLUMN_NAME = 'created_at'); +SET @sql := IF(@cnt = 1, + "UPDATE `featherpanel_vm_logs` + SET `created_at` = CONVERT_TZ(`created_at`, 'SYSTEM', '+00:00') + WHERE `created_at` IS NOT NULL AND `created_at` < @migration_cutoff_utc", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- vm_nodes.created_at + updated_at +SET @cnt := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'featherpanel_vm_nodes' AND COLUMN_NAME IN ('created_at','updated_at')); +SET @sql := IF(@cnt = 2, + "UPDATE `featherpanel_vm_nodes` + SET `created_at` = CONVERT_TZ(`created_at`, 'SYSTEM', '+00:00'), + `updated_at` = CONVERT_TZ(`updated_at`, 'SYSTEM', '+00:00') + WHERE (`created_at` IS NOT NULL AND `created_at` < @migration_cutoff_utc) + OR (`updated_at` IS NOT NULL AND `updated_at` < @migration_cutoff_utc)", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- vm_ssh_keys.created_at + updated_at +SET @cnt := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'featherpanel_vm_ssh_keys' AND COLUMN_NAME IN ('created_at','updated_at')); +SET @sql := IF(@cnt = 2, + "UPDATE `featherpanel_vm_ssh_keys` + SET `created_at` = CONVERT_TZ(`created_at`, 'SYSTEM', '+00:00'), + `updated_at` = CONVERT_TZ(`updated_at`, 'SYSTEM', '+00:00') + WHERE (`created_at` IS NOT NULL AND `created_at` < @migration_cutoff_utc) + OR (`updated_at` IS NOT NULL AND `updated_at` < @migration_cutoff_utc)", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- vm_tasks.created_at + updated_at +SET @cnt := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'featherpanel_vm_tasks' AND COLUMN_NAME IN ('created_at','updated_at')); +SET @sql := IF(@cnt = 2, + "UPDATE `featherpanel_vm_tasks` + SET `created_at` = CONVERT_TZ(`created_at`, 'SYSTEM', '+00:00'), + `updated_at` = CONVERT_TZ(`updated_at`, 'SYSTEM', '+00:00') + WHERE (`created_at` IS NOT NULL AND `created_at` < @migration_cutoff_utc) + OR (`updated_at` IS NOT NULL AND `updated_at` < @migration_cutoff_utc)", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- vm_templates.created_at + updated_at +SET @cnt := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'featherpanel_vm_templates' AND COLUMN_NAME IN ('created_at','updated_at')); +SET @sql := IF(@cnt = 2, + "UPDATE `featherpanel_vm_templates` + SET `created_at` = CONVERT_TZ(`created_at`, 'SYSTEM', '+00:00'), + `updated_at` = CONVERT_TZ(`updated_at`, 'SYSTEM', '+00:00') + WHERE (`created_at` IS NOT NULL AND `created_at` < @migration_cutoff_utc) + OR (`updated_at` IS NOT NULL AND `updated_at` < @migration_cutoff_utc)", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/backend/storage/migrations/2026-05-17.23.45-add-server-schedule-timezone.sql b/backend/storage/migrations/2026-05-17.23.45-add-server-schedule-timezone.sql new file mode 100644 index 000000000..129dc6b12 --- /dev/null +++ b/backend/storage/migrations/2026-05-17.23.45-add-server-schedule-timezone.sql @@ -0,0 +1,15 @@ +-- Add a per-schedule timezone column so each server cron schedule can be +-- evaluated in whichever zone the user actually authored it for (e.g. a +-- nightly backup at 03:00 should mean 03:00 local for that user, not 03:00 +-- UTC). Defaults to 'UTC' so existing schedules keep their current +-- behaviour without any surprise time shift. +SET @cnt := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'featherpanel_server_schedules' + AND COLUMN_NAME = 'timezone' +); +SET @sql := IF(@cnt = 0, + "ALTER TABLE `featherpanel_server_schedules` ADD COLUMN `timezone` VARCHAR(64) NOT NULL DEFAULT 'UTC' AFTER `cron_minute`", + 'DO 0'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/backend/storage/migrations/2026-05-19.20.00-add-moderation-reasons.sql b/backend/storage/migrations/2026-05-19.20.00-add-moderation-reasons.sql new file mode 100644 index 000000000..1a7723c0f --- /dev/null +++ b/backend/storage/migrations/2026-05-19.20.00-add-moderation-reasons.sql @@ -0,0 +1,10 @@ +-- Ban / suspension metadata for staff visibility +ALTER TABLE `featherpanel_users` +ADD COLUMN `ban_reason` TEXT NULL DEFAULT NULL AFTER `banned`, +ADD COLUMN `banned_at` DATETIME NULL DEFAULT NULL AFTER `ban_reason`, +ADD COLUMN `banned_by_uuid` CHAR(36) NULL DEFAULT NULL AFTER `banned_at`; + +ALTER TABLE `featherpanel_servers` +ADD COLUMN `suspension_reason` TEXT NULL DEFAULT NULL AFTER `suspended`, +ADD COLUMN `suspended_at` DATETIME NULL DEFAULT NULL AFTER `suspension_reason`, +ADD COLUMN `suspended_by_uuid` CHAR(36) NULL DEFAULT NULL AFTER `suspended_at`; diff --git a/backend/storage/migrations/2026-05-19.20.30-add-vm-moderation-reasons.sql b/backend/storage/migrations/2026-05-19.20.30-add-vm-moderation-reasons.sql new file mode 100644 index 000000000..ffbcbf57a --- /dev/null +++ b/backend/storage/migrations/2026-05-19.20.30-add-vm-moderation-reasons.sql @@ -0,0 +1,5 @@ +-- Suspension metadata for VDS/VM instances (staff visibility) +ALTER TABLE `featherpanel_vm_instances` +ADD COLUMN `suspension_reason` TEXT NULL DEFAULT NULL AFTER `suspended`, +ADD COLUMN `suspended_at` DATETIME NULL DEFAULT NULL AFTER `suspension_reason`, +ADD COLUMN `suspended_by_uuid` CHAR(36) NULL DEFAULT NULL AFTER `suspended_at`; diff --git a/backend/storage/migrations/2026-05-20.12.00-add-spell-sort-order.sql b/backend/storage/migrations/2026-05-20.12.00-add-spell-sort-order.sql new file mode 100644 index 000000000..2faf340b6 --- /dev/null +++ b/backend/storage/migrations/2026-05-20.12.00-add-spell-sort-order.sql @@ -0,0 +1,5 @@ +-- Custom display order for spells within each realm +ALTER TABLE `featherpanel_spells` +ADD COLUMN `sort_order` INT NOT NULL DEFAULT 0 AFTER `realm_id`; + +CREATE INDEX `spells_realm_sort_order_index` ON `featherpanel_spells` (`realm_id`, `sort_order`); diff --git a/frontendv2/package.json b/frontendv2/package.json index 4d052007f..3ce7f0980 100644 --- a/frontendv2/package.json +++ b/frontendv2/package.json @@ -1,6 +1,6 @@ { "name": "frontendv2", - "version": "1.3.7+2", + "version": "1.3.7+3", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { @@ -47,7 +47,8 @@ "axios": "^1.16.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "date-fns": "^4.1.0", + "date-fns": "^4.2.1", + "date-fns-tz": "^3.2.0", "js-yaml": "^4.1.1", "lucide-react": "^1.16.0", "next": "16.2.6", diff --git a/frontendv2/pnpm-lock.yaml b/frontendv2/pnpm-lock.yaml index ec976bddb..285f6d79f 100644 --- a/frontendv2/pnpm-lock.yaml +++ b/frontendv2/pnpm-lock.yaml @@ -81,8 +81,11 @@ importers: specifier: ^2.1.1 version: 2.1.1 date-fns: - specifier: ^4.1.0 - version: 4.1.0 + specifier: ^4.2.1 + version: 4.2.1 + date-fns-tz: + specifier: ^3.2.0 + version: 3.2.0(date-fns@4.2.1) js-yaml: specifier: ^4.1.1 version: 4.1.1 @@ -1690,8 +1693,13 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} - date-fns@4.1.0: - resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + date-fns-tz@3.2.0: + resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} + peerDependencies: + date-fns: ^3.0.0 || ^4.0.0 + + date-fns@4.2.1: + resolution: {integrity: sha512-37RhSdxaG1suen6VDCza6rNrQfooyQh57HFVPwQGEq2QWliVLzPQZ8Oa017weOu+HZCnzI7N3Pf/wyoBKfEqrA==} debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} @@ -5084,7 +5092,11 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - date-fns@4.1.0: {} + date-fns-tz@3.2.0(date-fns@4.2.1): + dependencies: + date-fns: 4.2.1 + + date-fns@4.2.1: {} debug@3.2.7: dependencies: diff --git a/frontendv2/public/icanhasfeatherpanel/events/allocations.html b/frontendv2/public/icanhasfeatherpanel/events/allocations.html index e5c9b2f84..71c6ef8b3 100644 --- a/frontendv2/public/icanhasfeatherpanel/events/allocations.html +++ b/frontendv2/public/icanhasfeatherpanel/events/allocations.html @@ -189,7 +189,7 @@

featherpanel:admin:allocations:allocation:updated

Callback parameters: int allocation id, array old data, array new data.

Event Data

-

Data keys: allocation, updated_by, updated_data

+

Data keys: allocation, deleted_source_conflicts, deleted_target_conflicts, from_ip, ip_alias, matched_count, node_id, to_ip, updated_by, updated_count, updated_data

Emitted From