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
@@ -202,9 +202,9 @@ Usage Example
public static function processEvents(PluginEvents $evt): void
{
- $evt->on(AllocationsEvent::onAllocationUpdated(), function ($allocation, $updatedBy, $updatedData) {
+ $evt->on(AllocationsEvent::onAllocationUpdated(), function ($allocation, $deletedSourceConflicts, $deletedTargetConflicts, $fromIp, $ipAlias, $matchedCount, $nodeId, $toIp, $updatedBy, $updatedCount, $updatedData) {
// Handle featherpanel:admin:allocations:allocation:updated
- // 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
});
}
diff --git a/frontendv2/public/icanhasfeatherpanel/events/index.html b/frontendv2/public/icanhasfeatherpanel/events/index.html
index 4cebee736..cde6db998 100644
--- a/frontendv2/public/icanhasfeatherpanel/events/index.html
+++ b/frontendv2/public/icanhasfeatherpanel/events/index.html
@@ -29,7 +29,7 @@ Plugin Events & Hooks
49 event categories
- 390 total events
+ 391 total events
diff --git a/frontendv2/public/icanhasfeatherpanel/events/server.html b/frontendv2/public/icanhasfeatherpanel/events/server.html
index b53b98bed..abbb24c9d 100644
--- a/frontendv2/public/icanhasfeatherpanel/events/server.html
+++ b/frontendv2/public/icanhasfeatherpanel/events/server.html
@@ -1258,11 +1258,11 @@ featherpanel:server:transfer:initiated
Callback parameters: array server data, array source_node, array destination_node, array initiated_by.
Event Data
- Data keys: destination_node, initiated_by, server, source_node
+ Data keys: N/A
Emitted From
-backend/app/Controllers/Admin/ServersController.php
+- No known emission locations.
Usage Example
@@ -1271,9 +1271,9 @@ Usage Example
public static function processEvents(PluginEvents $evt): void
{
- $evt->on(ServerEvent::onServerTransferInitiated(), function ($destinationNode, $initiatedBy, $server, $sourceNode) {
+ $evt->on(ServerEvent::onServerTransferInitiated(), function ($data, $source_node, $destination_node, $initiated_by) {
// Handle featherpanel:server:transfer:initiated
- // Data keys: destination_node, initiated_by, server, source_node
+ // Parameters: array server data, array source_node, array destination_node, array initiated_by.
});
}
diff --git a/frontendv2/public/icanhasfeatherpanel/events/spells.html b/frontendv2/public/icanhasfeatherpanel/events/spells.html
index 69cf052e0..2581e03ac 100644
--- a/frontendv2/public/icanhasfeatherpanel/events/spells.html
+++ b/frontendv2/public/icanhasfeatherpanel/events/spells.html
@@ -24,7 +24,7 @@
← Back to all event categories
@@ -235,6 +235,32 @@ Usage Example
}
+
+ featherpanel:admin:spells:reordered
+ Method: onSpellsReordered
+ Callback parameters: int realm id, array spells, array reordered_by.
+
+ Event Data
+ Data keys: realm_id, reordered_by, spells
+
+ Emitted From
+
+backend/app/Controllers/Admin/SpellsController.php
+
+
+ Usage Example
+ use App\Plugins\PluginEvents;
+use App\Plugins\Events\Events\SpellsEvent;
+
+public static function processEvents(PluginEvents $evt): void
+{
+ $evt->on(SpellsEvent::onSpellsReordered(), function ($realmId, $reorderedBy, $spells) {
+ // Handle featherpanel:admin:spells:reordered
+ // Data keys: realm_id, reordered_by, spells
+ });
+}
+
+
featherpanel:admin:spells:retrieved
Method: onSpellsRetrieved
diff --git a/frontendv2/public/icanhasfeatherpanel/events/ticket.html b/frontendv2/public/icanhasfeatherpanel/events/ticket.html
index 72b663372..6c8190553 100644
--- a/frontendv2/public/icanhasfeatherpanel/events/ticket.html
+++ b/frontendv2/public/icanhasfeatherpanel/events/ticket.html
@@ -221,6 +221,7 @@ Emitted From
backend/app/Controllers/Admin/TicketsController.php
backend/app/Controllers/User/TicketsController.php
+backend/app/Controllers/User/User/SessionController.php
Usage Example
diff --git a/frontendv2/public/icanhasfeatherpanel/events/user.html b/frontendv2/public/icanhasfeatherpanel/events/user.html
index b75041c34..5cbf6fbb8 100644
--- a/frontendv2/public/icanhasfeatherpanel/events/user.html
+++ b/frontendv2/public/icanhasfeatherpanel/events/user.html
@@ -268,7 +268,7 @@ featherpanel:user:updated
Callback parameters: array user data, array updated data, array updated by.
Event Data
- Data keys: banned, updated_by, updated_data, user
+ Data keys: ban_reason, mail_verify, updated_by, updated_data, user
Emitted From
@@ -281,9 +281,9 @@ Usage Example
public static function processEvents(PluginEvents $evt): void
{
- $evt->on(UserEvent::onUserUpdated(), function ($banned, $updatedBy, $updatedData, $user) {
+ $evt->on(UserEvent::onUserUpdated(), function ($banReason, $mailVerify, $updatedBy, $updatedData, $user) {
// Handle featherpanel:user:updated
- // Data keys: banned, updated_by, updated_data, user
+ // Data keys: ban_reason, mail_verify, updated_by, updated_data, user
});
}
diff --git a/frontendv2/public/locales/en.json b/frontendv2/public/locales/en.json
index b913068e1..f4940d0eb 100644
--- a/frontendv2/public/locales/en.json
+++ b/frontendv2/public/locales/en.json
@@ -30,7 +30,16 @@
"continue_with_password": "Enter your password to continue",
"request_login_code": "Request Login Code",
"use_password_instead": "Use Password Instead",
- "use_different_account": "Use a different account"
+ "use_different_account": "Use a different account",
+ "ldapProvider": "LDAP Provider",
+ "ldapUsername": "LDAP Username",
+ "ldapUsernamePlaceholder": "Enter your LDAP username",
+ "selectLdapProvider": "Please select an LDAP provider",
+ "ldapLogin": "LDAP Login",
+ "ldapLoginDescription": "Use your directory account",
+ "ldapLoginTitle": "Directory sign in",
+ "ldapLoginSubtitle": "Choose your provider and sign in with your LDAP credentials.",
+ "loginWithLdap": "Login with LDAP"
},
"emailLogin": {
"title": "Email Login",
@@ -146,7 +155,9 @@
"failed": "Failed to verify email.",
"success_title": "Email verified",
"failed_title": "Verification failed",
- "continue_to_login": "Continue to login"
+ "continue_to_login": "Continue to login",
+ "resend": "Resend verification email",
+ "resend_sent": "If this account still needs verification, a new email is on its way."
},
"loginSuccess": "Login successful",
"ssoLoggingIn": "Logging in via SSO...",
@@ -227,6 +238,7 @@
"enable": "Enable",
"copiedToClipboard": "Copied to clipboard",
"copied_to_clipboard": "Copied to clipboard",
+ "copy_to_clipboard": "Copy to clipboard",
"reload": "Reload",
"retry": "Retry",
"actionsCannotBeUndone": "Actions cannot be undone",
@@ -259,7 +271,10 @@
"users": "users",
"confirm": "Confirm",
"search": "Search",
+ "select": "Select",
"copy": "Copy",
+ "format": "Format",
+ "format_document": "Format Document",
"time": {
"just_now": "Just now",
"hours_ago": "{count} hour{s} ago",
@@ -272,7 +287,10 @@
"copied": "Copied to clipboard",
"failed": "Failed",
"importing": "Importing...",
+ "not_available": "—",
"nA": "N/A",
+ "no_data": "No data",
+ "no_content": "No content",
"noPermission": "You do not have permission to perform this action.",
"pending": "Pending",
"pleaseFixErrors": "Please fix the errors below.",
@@ -297,11 +315,20 @@
"inactive": "Inactive",
"enabled": "Enabled",
"disabled": "Disabled",
- "save_changes": "Save Changes"
+ "dismiss": "Dismiss",
+ "closePanel": "Close panel",
+ "loadingApp": "Loading {appName}",
+ "initializingApplication": "Initializing application...",
+ "save_changes": "Save Changes",
+ "hacker_easter_egg": {
+ "title": "Look what you have done script kiddy.",
+ "description": "Hahahah how funny you are you think you're a hacker or something? Okay then, let's see how you get rid of me."
+ }
},
"branding": {
"running_on": "Running on {name} {version}",
- "copyright": "{company}"
+ "copyright": "{company}",
+ "powered_by": "Powered by FeatherPanel"
},
"maintenance": {
"title": "System Maintenance",
@@ -322,7 +349,9 @@
"username_length": "Username must be between {min} and {max} characters",
"email_length": "Email must be between {min} and {max} characters",
"password_length": "Password must be between {min} and {max} characters",
- "captcha_required": "Please complete the CAPTCHA verification"
+ "captcha_required": "Please complete the CAPTCHA verification",
+ "verification_code_6_digits": "Verification code must be 6 digits",
+ "email_required": "Email is required"
},
"dashboard": {
"title": "Dashboard",
@@ -490,14 +519,72 @@
"analyticsEnabled": "Allow analytics and external scripts",
"securitySettings": "Security Settings",
"securitySettingsDescription": "Manage your account security and authentication methods",
+ "preferences": {
+ "title": "Display Preferences",
+ "description": "Choose how dates and times are displayed across the panel."
+ },
+ "timezone": {
+ "title": "Timezone",
+ "description": "All dates and times shown in the panel will be converted to the timezone you select here. Leave on the default to follow your current device.",
+ "useBrowser": "Auto-detect from this device ({timezone})",
+ "current": "Currently using",
+ "saved": "Timezone updated",
+ "saveFailed": "Failed to update timezone"
+ },
"sessionManagement": "Session Management",
"sessionManagementDescription": "Log out of your account on this device",
"logout": "Logout",
+ "logoutFailed": "Logout failed",
"discordAccount": "Discord Account",
"discordAccountDescription": "Link or unlink your Discord account",
"linkDiscord": "Link Discord",
"unlinkDiscord": "Unlink Discord",
"linkedAs": "Linked as",
+ "discordUnlinkedSuccessfully": "Discord account unlinked successfully",
+ "discordUnlinkFailed": "Failed to unlink Discord account",
+ "oidcAccount": "OIDC Account",
+ "oidcAccountDescription": "Link an external single sign-on account to this FeatherPanel account.",
+ "linkOidc": "Link OIDC",
+ "unlinkOidc": "Unlink OIDC",
+ "selectOidcProvider": "Please select an OIDC provider",
+ "oidcLinkedSuccessfully": "OIDC account linked successfully",
+ "oidcUnlinkedSuccessfully": "OIDC account unlinked successfully",
+ "oidcLinkFailed": "Failed to link OIDC account",
+ "oidcAccessDenied": "OIDC access was denied by the provider.",
+ "oidcDiscoveryFailed": "OIDC discovery failed. Please check the provider issuer URL.",
+ "oidcEmailNotVerified": "Your OIDC email address is not verified. Verify your email with the identity provider, then try again.",
+ "oidcInvalidAudience": "The OIDC token audience does not match this FeatherPanel client.",
+ "oidcInvalidIdToken": "The identity provider returned an invalid OIDC ID token.",
+ "oidcInvalidIssuer": "The OIDC token issuer does not match the configured provider.",
+ "oidcInvalidState": "The OIDC login session expired or is invalid. Please try again.",
+ "oidcMissingCode": "The identity provider did not return an authorization code.",
+ "oidcMissingSubject": "The identity provider did not return a user subject identifier.",
+ "oidcNotConfigured": "This OIDC provider is not fully configured.",
+ "oidcProviderMissing": "Please select an OIDC provider.",
+ "oidcProviderNotFound": "The selected OIDC provider is unavailable.",
+ "oidcTokenFailed": "The identity provider rejected the token request. Please check the OIDC client configuration.",
+ "oidcUserNotFound": "No FeatherPanel account matches this OIDC identity.",
+ "oidcUnlinkFailed": "Failed to unlink OIDC account",
+ "ldapAccount": "LDAP Account",
+ "ldapAccountDescription": "Link your directory credentials so this account can sign in with LDAP.",
+ "linkLdap": "Link LDAP",
+ "unlinkLdap": "Unlink LDAP",
+ "ldapUsernamePlaceholder": "LDAP username",
+ "ldapPasswordPlaceholder": "LDAP password",
+ "ldapMissingFields": "Please select a provider and enter your LDAP username and password",
+ "ldapLinkedSuccessfully": "LDAP account linked successfully",
+ "ldapUnlinkedSuccessfully": "LDAP account unlinked successfully",
+ "ldapLinkFailed": "Failed to link LDAP account",
+ "ldapUnlinkFailed": "Failed to unlink LDAP account",
+ "dataRequest": {
+ "title": "Request Your Data",
+ "description": "Create a support ticket asking the host for a copy of the personal data associated with your account.",
+ "ticketSystemDisabledDescription": "Data requests use support tickets, but the ticket system is currently disabled.",
+ "button": "Request Data",
+ "success": "Data request created successfully",
+ "failed": "Failed to create data request",
+ "ticketSystemDisabled": "The ticket system is currently disabled"
+ },
"sshKeys": {
"title": "SSH Keys",
"description": "Manage your SSH keys for secure server access",
@@ -629,15 +716,25 @@
"statuses": {
"active": "Active"
},
- "deleteFailed": "Failed to delete API key"
+ "deleteFailed": "Failed to delete API key",
+ "loadClientsFailed": "Failed to load API clients",
+ "loadClientFailed": "Failed to load API client",
+ "updateClientFailed": "Failed to update API client",
+ "regenerateFailed": "Failed to regenerate API keys"
},
"activity": {
"title": "Activity Log",
"description": "View your recent account activity",
"noActivities": "No activities yet",
+ "emptyDescription": "Your recent activity will appear here",
"loading": "Loading activity...",
+ "loadFailed": "Failed to load activity log",
"refresh": "Refresh",
- "searchPlaceholder": "Search activity..."
+ "searchPlaceholder": "Search activity...",
+ "showingActivities": "Showing {from} to {to} of {total} activities",
+ "totalActivities": "{count} activities",
+ "noSearchResults": "No search results",
+ "tryDifferentSearch": "Try a different search term"
},
"mail": {
"title": "Mail List",
@@ -671,7 +768,9 @@
"enabled": "Enabled",
"enable": "Enable 2FA",
"disable": "Disable 2FA",
- "description": "Add an extra layer of security to your account"
+ "description": "Add an extra layer of security to your account",
+ "disabledSuccessfully": "2FA disabled successfully",
+ "disableFailed": "Failed to disable 2FA"
}
},
"servers": {
@@ -1238,6 +1337,7 @@
"ticketPriorities": "Ticket Priorities",
"ticketStatuses": "Ticket Statuses",
"files": "Files",
+ "trash": "Trash",
"databases": "Databases",
"schedules": "Schedules",
"lifecycleHooks": "Lifecycle Hooks",
@@ -1318,10 +1418,42 @@
"messages": {
"update_started": "Update process started successfully.",
"update_failed": "Failed to start update.",
- "checking_updates": "Checking for updates..."
+ "checking_updates": "Checking for updates...",
+ "bulk_starting": "Starting bulk updates...",
+ "bulk_started": "Bulk update process initiated!",
+ "bulk_failed": "Some updates failed to start."
+ }
+ },
+ "sidebar": {
+ "close": "Close sidebar",
+ "server_switcher": {
+ "title": "Switch server",
+ "switch": "Switch server",
+ "current": "Current server",
+ "loading": "Loading servers…",
+ "view_all": "View all servers"
}
},
"navbar": {
+ "server_switcher": {
+ "title": "Switch server",
+ "switch": "Switch server",
+ "current": "Current server",
+ "loading": "Loading servers…",
+ "empty": "No servers found",
+ "empty_favorites": "No favorite servers yet. Star a server in the list to pin it here.",
+ "empty_recent": "No recently viewed servers yet.",
+ "empty_search": "No servers match your search.",
+ "search_placeholder": "Search servers…",
+ "clear_search": "Clear search",
+ "tabs_label": "Server list filters",
+ "tabs": {
+ "all": "All",
+ "favorites": "Favorites",
+ "recent": "Recent"
+ },
+ "view_all": "View all servers"
+ },
"profile": "Your Profile",
"adminArea": "Admin Area",
"adminPanelTooltip": "Admin Panel",
@@ -1566,6 +1698,13 @@
"createSuccess": "Backup created successfully",
"restoreFailed": "Failed to restore backup",
"restoreSuccess": "Backup restoration initiated successfully",
+ "startSuccess": "Backup started. This may take a few minutes.",
+ "startFailed": "Failed to start backup",
+ "deleteSuccessShort": "Backup deleted",
+ "restoreStarted": "Restore started. This may take several minutes.",
+ "restoreStartFailed": "Failed to start restore",
+ "restoreTakingLong": "Restore is taking longer than expected. Please check manually.",
+ "restoreCompleted": "Restore completed successfully!",
"downloadFailed": "Failed to get download URL",
"downloadSuccess": "Download URL generated successfully",
"deleteFailed": "Failed to delete backup",
@@ -1801,7 +1940,9 @@
"loadFailed": "Failed to load subdomains.",
"configuration": "Configuration",
"helpfulTips": "Helpful Tips",
- "guide": "Guide"
+ "guide": "Guide",
+ "guide_custom_address": "Subdomains allow you to give your server a custom address, like",
+ "guide_multiple": "You can create multiple subdomains for different purposes."
},
"serverSchedules": {
"title": "Schedules",
@@ -1823,6 +1964,8 @@
"dayOfMonth": "Day (Month)",
"month": "Month",
"dayOfWeek": "Day (Week)",
+ "timezone": "Timezone",
+ "timezoneHelp": "The timezone the cron expression above is interpreted in. For example, '0 3 * * *' with 'Europe/Paris' means 3 AM Paris time, not 3 AM UTC.",
"onlyWhenOnline": "Only When Online",
"onlyWhenOnlineHelp": "Should this schedule only run when the server is online?",
"scheduleEnabled": "Schedule Enabled",
@@ -1837,6 +1980,8 @@
"createFailed": "Failed to create schedule.",
"updateSuccess": "Schedule updated successfully.",
"updateFailed": "Failed to update schedule.",
+ "loadFailed": "Failed to load schedule.",
+ "nameRequired": "Schedule name is required.",
"deleteSuccess": "Schedule deleted successfully.",
"deleteFailed": "Failed to delete schedule.",
"toggleSuccess": "Schedule status updated.",
@@ -1865,7 +2010,11 @@
"clickToUploadJson": "Click to upload a .json file",
"orPasteJson": "or paste JSON",
"nextRun": "Next:",
- "showing": "Showing {from}–{to} of {total}"
+ "showing": "Showing {from}–{to} of {total}",
+ "options": "Options",
+ "configuration": "Configuration",
+ "runRegardless": "No - Run regardless of server status",
+ "runOnlyOnline": "Yes - Only run when server is online"
},
"serverTasks": {
"title": "Schedule Tasks",
@@ -2528,16 +2677,81 @@
"upload_files": "Upload files",
"upload_folders": "Upload folder(s)",
"ignored_content": "Ignored Content",
- "wipe_all": "Wipe All"
+ "wipe_all": "Wipe All",
+ "trash": "Trash"
},
"list": {
"header_name": "Name",
"header_size": "Size",
"header_modified": "Last Modified",
+ "header_actions": "Actions",
"empty": {},
"empty_description": "This directory is empty.",
"empty_title": "No Files Found"
},
+ "trash": {
+ "title": "Trash",
+ "subtitle": "Restore or permanently remove deleted files from your server.",
+ "description": "{count} item(s) using {size} in trash",
+ "disabled": "The file trash bin is disabled by your administrator.",
+ "back_to_files": "Back to file manager",
+ "loading": "Loading trash...",
+ "empty_title": "Trash is empty",
+ "empty": "Deleted files from the file manager will appear here until restored or purged.",
+ "restore": "Restore",
+ "delete_permanent": "Delete permanently",
+ "empty_all": "Empty trash",
+ "stats": {
+ "items": "Items",
+ "size": "Total size",
+ "selected": "Selected"
+ },
+ "list": {
+ "header_name": "Name",
+ "header_size": "Size",
+ "header_deleted": "Deleted"
+ },
+ "empty_dialog": {
+ "title": "Empty trash",
+ "description": "Permanently delete every item in trash. This cannot be undone.",
+ "cancel": "Cancel",
+ "confirm": "Empty trash",
+ "confirming": "Emptying..."
+ },
+ "restore_dialog": {
+ "title": "Restore from trash",
+ "description": "Restore {count} selected item(s) to their original location.",
+ "overwrite_label": "Replace existing files",
+ "overwrite_hint": "If the server recreated a file with the same name (for example banned-players.json), enable this to replace it with the trashed copy.",
+ "cancel": "Cancel",
+ "confirm": "Restore",
+ "confirming": "Restoring..."
+ },
+ "messages": {
+ "load_error": "Failed to load trash",
+ "restored": "Restored from trash",
+ "restore_error": "Failed to restore files",
+ "deleted": "Permanently deleted from trash",
+ "delete_error": "Failed to delete trash items",
+ "emptied": "Trash emptied",
+ "empty_error": "Failed to empty trash"
+ }
+ },
+ "archive_browser": {
+ "title": "Browse archive",
+ "loading": "Loading...",
+ "empty": "This folder is empty.",
+ "error": "Could not read this archive. It may be unsupported, corrupted, or too large.",
+ "truncated": "Listing was truncated. Extract the archive to see everything.",
+ "up": "Up",
+ "close": "Close",
+ "drag_hint": "Drag files or folders onto a directory in the file manager to extract them there, or onto a breadcrumb to choose a parent path. Folders use the grip on the right.",
+ "drag_handle": "Drag to extract this item",
+ "extract_here": "Extract here",
+ "extract_root": "Extract to root",
+ "extract_full": "Extract entire archive here",
+ "extract_full_loading": "Extracting archive..."
+ },
"messages": {
"active_pull": "Active Pull",
"task_id": "Task ID: {id}",
@@ -2545,9 +2759,19 @@
"wings_connection_unavailable": "Wings Connection Unavailable. Please contact the support team.",
"contact_support": "If this problem persists, please contact the support team.",
"failed_download": "Failed to initiate download",
+ "request_timed_out": "Request timed out",
+ "load_timeout_retry": "File loading timed out. Please try again.",
+ "download_cancelled": "Download cancelled",
+ "cancel_download_failed": "Failed to cancel download",
+ "moved_to_trash": "Moved to trash",
+ "deleted": "Deleted successfully",
+ "delete_failed": "Failed to delete files",
"extracting": "Extracting archive...",
"extracted": "Archive extracted",
"extract_failed": "Failed to extract archive",
+ "archive_members_extracting": "Extracting from archive...",
+ "archive_members_extracted": "Extracted into folder",
+ "archive_members_extract_failed": "Could not extract from archive",
"uploading": "Uploading {file}...",
"upload_complete": "Upload complete",
"upload_failed": "Upload failed",
@@ -2587,6 +2811,7 @@
"contents_placeholder": "Search text in files...",
"search": "Search",
"stop": "Stop",
+ "search_failed": "Failed to search files",
"no_results": "No matches found in this folder",
"results_title": "Search results",
"result_item": "{count} matches in {file}",
@@ -2787,6 +3012,154 @@
"maxWorldSize": {
"label": "Max World Size",
"description": "Maximum world size in blocks"
+ },
+ "serverName": {
+ "label": "Server Name",
+ "description": "Internal server name used for logging"
+ },
+ "serverIp": {
+ "label": "Server IP",
+ "description": "Bind address (leave empty for all interfaces)"
+ },
+ "serverPort": {
+ "label": "Server Port",
+ "description": "Port the Minecraft server listens on"
+ },
+ "networkCompressionThreshold": {
+ "label": "Network Compression Threshold",
+ "description": "Packet size threshold for compression (-1 to disable)"
+ },
+ "rateLimit": {
+ "label": "Rate Limit",
+ "description": "Maximum packets per second (0 to disable)"
+ },
+ "playerIdleTimeout": {
+ "label": "Player Idle Timeout",
+ "description": "Minutes before idle players are kicked (0 to disable)"
+ },
+ "pauseWhenEmptySeconds": {
+ "label": "Pause When Empty (seconds)",
+ "description": "Seconds after last player leaves before pausing (-1 to disable)"
+ },
+ "enableStatus": {
+ "label": "Enable Status",
+ "description": "Respond to server list ping requests"
+ },
+ "preventProxyConnections": {
+ "label": "Prevent Proxy Connections",
+ "description": "Block players if ISP differs from the one used to join"
+ },
+ "logIps": {
+ "label": "Log IPs",
+ "description": "Log player IP addresses in the server log"
+ },
+ "acceptsTransfers": {
+ "label": "Accepts Transfers",
+ "description": "Allow players to transfer to this server from another server"
+ },
+ "enableRcon": {
+ "label": "Enable RCON",
+ "description": "Enable remote console access"
+ },
+ "rconPort": {
+ "label": "RCON Port",
+ "description": "Port used for RCON connections"
+ },
+ "rconPassword": {
+ "label": "RCON Password",
+ "description": "Password required for RCON access"
+ },
+ "broadcastRconToOps": {
+ "label": "Broadcast RCON to OPs",
+ "description": "Send RCON command output to online operators"
+ },
+ "enableQuery": {
+ "label": "Enable Query",
+ "description": "Enable GameSpy4 query protocol"
+ },
+ "queryPort": {
+ "label": "Query Port",
+ "description": "Port used for query protocol responses"
+ },
+ "managementServerEnabled": {
+ "label": "Enable Management Server",
+ "description": "Enable the built-in management server API"
+ },
+ "managementServerHost": {
+ "label": "Management Server Host",
+ "description": "Host address for the management server"
+ },
+ "managementServerPort": {
+ "label": "Management Server Port",
+ "description": "Port for the management server (0 for automatic)"
+ },
+ "managementServerSecret": {
+ "label": "Management Server Secret",
+ "description": "Shared secret for management server authentication"
+ },
+ "managementServerAllowedOrigins": {
+ "label": "Allowed Origins",
+ "description": "Comma-separated list of allowed CORS origins"
+ },
+ "managementServerTlsEnabled": {
+ "label": "Management TLS Enabled",
+ "description": "Use TLS for management server connections"
+ },
+ "managementServerTlsKeystore": {
+ "label": "TLS Keystore Path",
+ "description": "Path to the TLS keystore file"
+ },
+ "managementServerTlsKeystorePassword": {
+ "label": "TLS Keystore Password",
+ "description": "Password for the TLS keystore"
+ },
+ "initialEnabledPacks": {
+ "label": "Initial Enabled Packs",
+ "description": "Comma-separated list of data packs enabled on world creation"
+ },
+ "initialDisabledPacks": {
+ "label": "Initial Disabled Packs",
+ "description": "Comma-separated list of data packs disabled on world creation"
+ },
+ "maxTickTime": {
+ "label": "Max Tick Time",
+ "description": "Maximum milliseconds per tick before watchdog stops the server"
+ },
+ "statusHeartbeatInterval": {
+ "label": "Status Heartbeat Interval",
+ "description": "Interval in seconds for status heartbeats (0 to disable)"
+ },
+ "regionFileCompression": {
+ "label": "Region File Compression",
+ "description": "Compression algorithm used for region files"
+ },
+ "syncChunkWrites": {
+ "label": "Sync Chunk Writes",
+ "description": "Synchronously write chunk data to disk"
+ },
+ "debug": {
+ "label": "Debug Mode",
+ "description": "Enable debug logging"
+ },
+ "enableJmxMonitoring": {
+ "label": "Enable JMX Monitoring",
+ "description": "Expose JMX monitoring beans"
+ },
+ "enableCodeOfConduct": {
+ "label": "Enable Code of Conduct",
+ "description": "Enable the Minecraft code of conduct system"
+ },
+ "bugReportLink": {
+ "label": "Bug Report Link",
+ "description": "URL shown to players for reporting bugs"
+ },
+ "textFilteringConfig": {
+ "label": "Text Filtering Config",
+ "description": "Path or configuration for chat text filtering"
+ },
+ "textFilteringVersion": {
+ "label": "Text Filtering Version",
+ "description": "Text filtering configuration version"
}
},
"sections": {
@@ -2796,7 +3169,10 @@
"network": "Network & Security",
"performance": "Performance Settings",
"resourcePack": "Resource Pack",
- "advanced": "Advanced Settings"
+ "advanced": "Advanced Settings",
+ "dataPacks": "Data Packs",
+ "rconQuery": "RCON & Query",
+ "managementServer": "Management Server"
},
"sectionsDescriptions": {
"serverInfo": "Basic server configuration",
@@ -2805,7 +3181,10 @@
"network": "Network and security settings",
"performance": "Server performance and rendering settings",
"resourcePack": "Resource pack configuration",
- "advanced": "Advanced server configuration"
+ "advanced": "Advanced server configuration",
+ "dataPacks": "Initial data pack configuration",
+ "rconQuery": "Remote console and query protocol settings",
+ "managementServer": "Built-in management API settings"
},
"options": {
"gamemode": {
@@ -2826,6 +3205,11 @@
"amplified": "Amplified",
"largeBiomes": "Large Biomes",
"singleBiome": "Single Biome"
+ },
+ "regionFileCompression": {
+ "deflate": "Deflate",
+ "none": "None",
+ "lz4": "LZ4"
}
}
},
@@ -3205,6 +3589,8 @@
"preview": "Preview",
"cant_preview": "Cannot preview this file",
"folder_label": "Folder",
+ "trash_open": "Open trash",
+ "trash_empty": "Empty trash",
"edit": "Edit",
"rename": "Rename",
"download": "Download",
@@ -3212,11 +3598,21 @@
"move": "Move",
"hash": "File Hash",
"extract": "Extract",
+ "browse_archive": "Browse archive",
"compress": "Compress",
"permissions": "Permissions",
"delete": "Delete"
},
"dialogs": {
+ "archive_browse": {
+ "title": "Browse archive",
+ "loading": "Loading…",
+ "empty": "This folder is empty.",
+ "error": "Could not read this archive. It may be unsupported, corrupted, or too large.",
+ "truncated": "Listing was truncated. Extract the archive to see everything.",
+ "up": "Up",
+ "close": "Close"
+ },
"create_file": {
"title": "Create New File",
"description": "Create a new file in {root}",
@@ -3240,7 +3636,12 @@
},
"delete": {
"title": "Delete Files",
+ "title_trash": "Delete files",
"description": "Are you sure you want to delete {count} item(s)? This action cannot be undone.",
+ "description_trash": "Move {count} selected item(s) to trash. You can restore them from the Trash page.",
+ "move_to_trash": "Move to trash",
+ "moving_to_trash": "Moving to trash…",
+ "delete_permanent_short": "Delete permanently",
"cancel": "Cancel",
"delete": "Delete",
"deleting": "Deleting..."
@@ -3358,6 +3759,37 @@
},
"admin": {
"navbar": {},
+ "moderation": {
+ "reason_category_label": "Reason category",
+ "reason_category_placeholder": "Select a category (optional)",
+ "reason_details_label": "Details for staff",
+ "reason_details_placeholder": "Describe why this action was taken. Visible to staff only.",
+ "reason_details_help": "Provide a category, at least 3 characters of detail, or both. This helps other staff understand the decision.",
+ "reason_required": "A reason is required when suspending or banning.",
+ "status_reason": "Reason",
+ "status_by": "Actioned by",
+ "status_at": "Actioned at",
+ "no_reason_recorded": "No reason recorded",
+ "unknown_staff": "Unknown staff member",
+ "unknown_time": "Unknown time",
+ "user_banned_title": "Account ban active",
+ "user_not_banned": "This account is not banned.",
+ "server_suspended_title": "Server suspension active",
+ "server_not_suspended": "This server is not suspended.",
+ "vds_suspended_title": "VDS suspension active",
+ "vds_not_suspended": "This VDS instance is not suspended.",
+ "categories": {
+ "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",
+ "other": "Other"
+ }
+ },
"affiliates": {
"title": "Recommended Partners",
"description": "Affiliate hosting partners for game and VDS infrastructure.",
@@ -3853,6 +4285,8 @@
"field_lxc_root_password": "Default LXC root password",
"field_lxc_root_password_placeholder": "e.g. P@ssw0rd (shown to users after deploy)",
"field_lxc_root_password_help": "Informational only. FeatherPanel does not change the root password on the container; this value is just displayed to users as the default root password for LXC instances created from this template.",
+ "security_recommendation": "Security Recommendation",
+ "lxc_security_warning": "LXC is not recommended for public hosting due to security risks.",
"create_success": "Template added successfully.",
"create_failed": "Failed to add template.",
"delete_success": "Template deleted.",
@@ -4095,6 +4529,12 @@
"fetch_failed": "Failed to load IP pool.",
"add_success": "IP added successfully.",
"add_failed": "Failed to add IP.",
+ "single": "Single",
+ "bulk": "Bulk",
+ "bulk_list": "IP list",
+ "bulk_help": "One IP per line, or comma/space separated. CIDR/gateway/notes apply to all.",
+ "bulk_empty": "Add at least one IP (one per line, comma, or space separated).",
+ "bulk_invalid_ip": "Invalid IP in list: {ip}",
"update_success": "IP updated successfully.",
"update_failed": "Failed to update IP.",
"delete_success": "IP deleted successfully.",
@@ -4268,6 +4708,15 @@
"resize_disk": "Expand disk",
"disk_size_placeholder": "e.g. +5G or 20G",
"resize_success": "Disk resized successfully.",
+ "resize_input_required": "Select a disk and enter size (e.g. +5G)",
+ "infrastructure_required_to_continue": "Add at least one VPS location and one VDS node before continuing.",
+ "infrastructure_required": "Infrastructure required",
+ "create_vps_location_first": "Create at least one VPS location first.",
+ "create_vds_node_first": "Create at least one VDS node and link it to a location.",
+ "infrastructure_counts": "{locations} VPS location(s) · {nodes} VDS node(s)",
+ "recheck_infrastructure": "Recheck infrastructure",
+ "create_ip_and_assign": "Create IP and assign",
+ "initiating_creation": "Initiating VM creation…",
"network_multi_hint": "Add or remove IPs (Proxmox net0, net1, …). Select IP from pool for each interface.",
"network_multi_qemu_hint": "Add or remove IPs for this VM. FeatherPanel will keep virtual NICs and cloud-init ipconfig entries aligned automatically.",
"add_ip": "Add IP",
@@ -4275,6 +4724,8 @@
"secondary_ip": "Secondary",
"disks_list": "Disks",
"disk_storage": "Storage",
+ "resize_disk_label": "Disk",
+ "resize_size_label": "Size",
"disk_size_gb": "Size (GB)",
"disk_path": "Mount path (optional)",
"add_disk": "Add disk",
@@ -4497,6 +4948,8 @@
},
"suspend": "Suspend",
"unsuspend": "Unsuspend",
+ "suspend_confirm_title": "Suspend VDS instance?",
+ "suspend_confirm_description": "Provide a reason for staff before suspending {hostname}. The owner will be notified by email.",
"suspend_success": "VDS instance suspended successfully",
"unsuspend_success": "VDS instance unsuspended successfully"
},
@@ -4610,6 +5063,33 @@
"confirm_delete": "Confirm Delete",
"cancel_delete": "Cancel"
},
+ "mass_transfer": {
+ "button": "Mass move servers",
+ "title": "Mass move servers",
+ "description": "Move servers from {node} to another node. Free allocations on the destination are assigned automatically — no need to pick an IP for each server.",
+ "destination_node": "Destination node",
+ "select_destination": "Select destination node...",
+ "transferable": "Can move",
+ "allocations_needed": "Allocations needed",
+ "free_on_destination": "Free on destination",
+ "batch_limit": "Max per run",
+ "insufficient_allocations": "The destination node does not have enough free allocations for your selection.",
+ "auto_allocate_note": "Primary and extra ports are filled from unused allocations on the destination node, in IP/port order.",
+ "move_all": "Move all ({count})",
+ "select_all": "Select all",
+ "deselect_all": "Deselect all",
+ "no_transferable": "No servers on this node can be moved right now.",
+ "allocations_per_server": "{count} allocation(s)",
+ "skipped_count": "{count} server(s) skipped (installing, restoring, or already transferring).",
+ "start": "Move {count} server(s)",
+ "confirm_title": "Start mass move?",
+ "confirm_description": "Move {count} server(s) from {source} to {destination}. Servers will be stopped during transfer. Backups on the old node are removed from the panel.",
+ "confirm_submit": "Start transfers",
+ "complete_summary": "Mass move: {initiated} started, {failed} failed, {skipped} skipped.",
+ "failed": "Mass move failed",
+ "preview_failed": "Could not load move preview",
+ "fetch_nodes_failed": "Failed to load nodes"
+ },
"help": {
"what": {
"title": "What are Nodes?",
@@ -4639,6 +5119,7 @@
"create_allocation": "Create Allocation",
"delete_unused": "Delete Unused",
"delete_selected": "Delete Selected",
+ "change_address": "Change IP/Alias",
"ip_address": "IP Address",
"port": "Port",
"ip_alias": "IP Alias",
@@ -4685,6 +5166,16 @@
"title": "Edit Allocation",
"description": "Edit details for allocation: {ip}:{port}"
},
+ "bulk_address": {
+ "title": "Change Allocation IP/Alias",
+ "description": "Update every allocation on this node that currently uses a specific IP address. Assigned allocations are updated in place.",
+ "from_ip": "Current IP Address",
+ "to_ip": "New IP Address",
+ "to_ip_help": "Leave blank if you only want to update the alias.",
+ "update_alias": "Update IP alias",
+ "alias_help": "When enabled, leave the alias blank to clear it for the matching allocations.",
+ "submit": "Update Allocations"
+ },
"no_results": "No allocations found matching your search.",
"messages": {
"fetch_failed": "Failed to fetch allocations",
@@ -4692,6 +5183,9 @@
"create_failed": "Failed to create allocations",
"update_success": "Allocation updated successfully",
"update_failed": "Failed to update allocation",
+ "bulk_address_success": "Allocation IP/Alias updated successfully",
+ "bulk_address_failed": "Failed to update allocation IP/Alias",
+ "bulk_address_missing": "Enter a current IP and either a new IP or alias update.",
"delete_success": "Allocation deleted successfully",
"delete_failed": "Failed to delete allocation",
"bulk_delete_success": "Selected allocations deleted successfully",
@@ -4721,7 +5215,8 @@
"restart_checkbox": "Restart Wings after saving",
"restart_help": "If enabled, Wings will be restarted to apply the new configuration immediately.",
"restart_notice": "Wings is being restarted to apply the new configuration.",
- "save_success": "Configuration saved successfully."
+ "save_success": "Configuration saved successfully.",
+ "load_failed_title": "Failed to Load Configuration"
},
"diagnostics": {
"title": "Diagnostics",
@@ -4783,6 +5278,8 @@
"configure_disabled_notice": "You must disable this module before you can change its configuration.",
"config_save_success": "Configuration for {name} saved successfully.",
"config_save_failed": "Failed to save configuration for {name}.",
+ "config_fetch_failed": "Failed to fetch module configuration",
+ "invalid_json_config": "Invalid JSON configuration",
"enabled_badge": "Enabled",
"disabled_badge": "Disabled",
"no_modules": "No Modules Found",
@@ -4955,6 +5452,7 @@
"description": "Manage all servers in your system.",
"search_placeholder": "Search by name, description, or status...",
"no_results": "No servers found",
+ "owner_last_seen": "Owner last seen",
"create": "Create Server",
"filters": {
"advanced": "Advanced filters",
@@ -5082,6 +5580,11 @@
"destination_allocation": "Destination Primary Allocation",
"select_node": "Select destination node...",
"select_allocation": "Select destination allocation...",
+ "auto_allocate": "Assign allocation automatically",
+ "auto_allocate_help": "Uses the next free allocation(s) on the destination node for the primary port and any extra ports.",
+ "search_nodes": "Search nodes...",
+ "search_allocations": "Search allocations...",
+ "no_free_allocations": "No free allocations found",
"current_node": "Current Node",
"server": "Server",
"warning_banner": "⚠️ USE AT YOUR OWN RISK ⚠️",
@@ -5255,6 +5758,11 @@
"docker_image_required": "Docker image is required",
"startup_required": "Startup command is required"
}
+ },
+ "messages": {
+ "spell_details_failed": "Failed to fetch spell details",
+ "created": "Server created successfully!",
+ "create_failed": "Failed to create server"
}
},
"edit": {
@@ -5344,6 +5852,8 @@
"active": "Active",
"suspend": "Suspend",
"unsuspend": "Unsuspend",
+ "suspend_confirm_title": "Suspend Server?",
+ "suspend_confirm_description": "Provide a reason for staff before suspending {name}. The owner will be notified by email.",
"suspend_success": "Server suspended successfully",
"suspend_failed": "Failed to suspend server",
"unsuspend_success": "Server unsuspended successfully",
@@ -5386,6 +5896,7 @@
},
"sidebar": {
"ticket_info": "Ticket Information",
+ "user_info_unavailable": "User info unavailable",
"tabs": {},
"labels": {
"id": "ID",
@@ -5543,6 +6054,8 @@
},
"view": {
"original_request": "Original Request",
+ "mail_preview_title": "Email Preview",
+ "email_body": "Email Body",
"edit": "Edit Ticket",
"close": "Close Ticket",
"reopen": "Reopen Ticket",
@@ -5727,6 +6240,10 @@
"last_7_days": "Last 7 days",
"last_30_days": "Last 30 days",
"most_active_time": "Most active time",
+ "total_activities": "Total activities",
+ "all_time_activity_events": "All-time platform activity events",
+ "top_activity_type": "Top activity type",
+ "no_activity_data": "No activity data",
"trend_title": "Activity Trend (7 Days)",
"trend_desc": "Daily activity volume over the last week",
"breakdown_title": "Activity Breakdown",
@@ -5760,7 +6277,12 @@
"assigned": "Assigned",
"available": "Available",
"disk_usage": "Disk Usage",
- "memory_usage": "Memory Usage"
+ "memory_usage": "Memory Usage",
+ "top_used_port": "Top used port",
+ "assigned_allocations": "Assigned: {count} allocations",
+ "top_ip_total_ports": "Top IP total ports",
+ "ip_usage": "{ip} ({percentage}% used)",
+ "no_allocation_usage_data": "No allocation usage data"
},
"languages": {
"title": "Language Analytics",
@@ -5789,6 +6311,10 @@
"in_use": "{percentage}% in use",
"library": "In library",
"active_links": "Active links",
+ "spells_with_variables": "Spells with variables",
+ "privileged_scripts": "Privileged scripts: {count}",
+ "mail_templates": "Mail templates",
+ "notification_templates": "Templates available for notifications",
"spells_by_realm": "Spells by Realm",
"spells_by_realm_desc": "Spell distribution across realms",
"variable_types": "Variable Types",
@@ -5833,6 +6359,14 @@
"disk_dist_desc": "Disk allocation ranges",
"age_dist": "Server Age Distribution",
"age_dist_desc": "How old are your servers",
+ "avg_db_limit": "Avg DB limit",
+ "avg_backup_limit": "Avg backup limit: {count}",
+ "skip_scripts_enabled": "Skip scripts enabled",
+ "oom_disabled": "OOM disabled: {count}",
+ "server_variables": "Server variables",
+ "custom_runtime_variables": "Custom runtime variable values",
+ "server_activity_events": "Server activity events",
+ "operational_events": "Operational events across all servers",
"unknown": "Unknown"
},
"system": {
@@ -5849,6 +6383,14 @@
"delivery_rate": "Delivery rate",
"queue_status": "Mail Queue Status",
"queue_status_desc": "Email queue distribution",
+ "chat_conversations": "Chat conversations",
+ "messages_count": "Messages: {count}",
+ "chat_users_30d": "Chat users (30d)",
+ "avg_messages_per_conversation": "Avg msgs/conversation: {count}",
+ "api_clients": "API clients",
+ "oauth2_authorizations": "OAuth2 authorizations: {count}",
+ "enabled_oidc_providers": "Enabled OIDC providers",
+ "identity_providers_configured": "Identity providers configured",
"recent_activity": "Recent Mail Queue Activity",
"recent_activity_desc": "Latest email processing status",
"no_activity": "No recent mail queue activity",
@@ -5870,6 +6412,10 @@
"banned_pct": "{percentage}% of total",
"verified_pct": "{percentage}% verified",
"two_fa_pct": "{percentage}% with 2FA",
+ "unverified": "Unverified",
+ "pending_email_verification": "Users pending email verification",
+ "not_secured": "Not secured",
+ "fully_secured": "Fully secured: {count}",
"reg_trend": "Registration Trend (30 Days)",
"reg_trend_desc": "Daily user registrations over the last 30 days",
"role_dist": "User Distribution by Role",
@@ -5893,6 +6439,77 @@
"growth_30d": "Growth Rate (30 days)",
"growth_comparison": "{new} new vs {previous} previous"
},
+ "vds": {
+ "title": "VDS Analytics",
+ "subtitle": "VDS-only KPIs for nodes, templates, instances, backups, and operations.",
+ "error": "Failed to fetch VDS analytics data.",
+ "instances": "Instances",
+ "nodes": "Nodes",
+ "templates": "Templates",
+ "backups": "Backups",
+ "tasks": "Tasks",
+ "subusers": "Subusers",
+ "instance_ips": "Instance IPs",
+ "activities": "Activities",
+ "vds_instances": "VDS Instances",
+ "vds_nodes": "VDS Nodes",
+ "instance_backups": "Instance Backups",
+ "instance_activities": "Instance Activities",
+ "nodes_templates": "Nodes: {nodes}, Templates: {templates}",
+ "templates_tasks": "Templates: {templates}, Tasks: {tasks}",
+ "ips_subusers": "IPs: {ips}, Subusers: {subusers}",
+ "total_vds_objects": "Total VDS objects: {count}",
+ "breakdown": "VDS Breakdown",
+ "breakdown_desc": "VDS-related object totals",
+ "totals": "VDS Totals",
+ "totals_desc": "Total VDS object count",
+ "vds_objects": "VDS Objects",
+ "runtime_breakdown": "VDS Runtime Breakdown",
+ "runtime_breakdown_desc": "Operational VDS entities and usage"
+ },
+ "knowledgebase": {
+ "title": "Knowledgebase Analytics",
+ "subtitle": "Knowledgebase content KPIs.",
+ "categories": "Categories",
+ "articles": "Articles",
+ "attachments": "Attachments",
+ "tags": "Tags",
+ "category_entries": "Knowledgebase category entries",
+ "published_articles": "Published/support articles",
+ "uploaded_attachments": "Uploaded article attachments",
+ "total_kb_objects": "Total KB Objects",
+ "all_entities": "All knowledgebase entities",
+ "breakdown": "Knowledgebase Breakdown",
+ "breakdown_desc": "Distribution by KB object type",
+ "objects": "Knowledgebase Objects",
+ "objects_desc": "Counts across KB entities"
+ },
+ "tickets": {
+ "title": "Tickets Analytics",
+ "subtitle": "Ticketing KPIs and usage metrics.",
+ "tickets": "Tickets",
+ "messages": "Messages",
+ "attachments": "Attachments",
+ "categories": "Categories",
+ "priorities": "Priorities",
+ "statuses": "Statuses",
+ "today": "Today",
+ "this_week": "This Week",
+ "last_week": "Last Week",
+ "total_tickets": "Total tickets",
+ "ticket_messages": "Ticket Messages",
+ "conversation_volume": "Conversation volume",
+ "ticket_attachments": "Ticket Attachments",
+ "uploaded_files": "Uploaded files",
+ "weekly_growth": "Weekly Growth",
+ "weekly_comparison": "{thisWeek} this week vs {lastWeek} last week",
+ "breakdown": "Ticket Breakdown",
+ "breakdown_desc": "Distribution by entity type",
+ "creation_velocity": "Ticket Creation Velocity",
+ "creation_velocity_desc": "Today and week-over-week ticket volume",
+ "recent_trend": "Recent Ticket Trend (14d)",
+ "recent_trend_desc": "Tickets created per day over the most recent 14 days"
+ },
"nav": {
"users_desc": "Comprehensive user statistics including registrations, roles, security, and growth metrics",
"activity_desc": "Track user activities, trends, peak hours, and activity type distribution across your panel",
@@ -6156,7 +6773,8 @@
"feathercloud": {
"translations": {
"download_coming_soon": "Download functionality will be available when the API is ready",
- "download_failed": "Failed to download translation"
+ "download_failed": "Failed to download translation",
+ "fetch_failed": "Failed to fetch community translations"
}
},
"cloud_management": {
@@ -6244,17 +6862,27 @@
"warning1": "New keys will be generated immediately",
"warning2": "You must update your FeatherCloud account with the new keys",
"warning3": "Old keys will no longer work after rotation",
- "warning4": "Cloud credentials are currently empty - premium plugins cannot be downloaded until FeatherCloud credentials are configured",
+ "warning4": "After rotation you must use Link with FeatherCloud again so FeatherCloud can issue new API keys; premium and cloud features stay offline until OAuth completes",
"cancel": "Cancel",
"confirm": "Rotate Keys",
"rotating": "Rotating..."
},
+ "messages": {
+ "credentials_load_failed": "Failed to load cloud credentials",
+ "credentials_rotated": "Keys rotated. Link with FeatherCloud again (OAuth) to restore cloud API access.",
+ "credentials_rotate_failed": "Failed to rotate cloud credentials",
+ "cloud_credentials_empty": "Cloud credentials are empty. Premium plugins cannot be downloaded until FeatherCloud credentials are configured.",
+ "oauth_link_failed": "Failed to generate OAuth2 link"
+ },
"finish": {
"processing": "Processing OAuth2 Callback...",
"success": "Successfully Connected!",
"failed": "Connection Failed",
"processing_desc": "Please wait while we save your cloud credentials...",
"success_desc": "Your panel is now linked with FeatherCloud. You can now access premium plugins, FeatherAI, and cloud intelligence services.",
+ "linked_toast": "Your panel has been successfully linked with FeatherCloud!",
+ "save_failed": "Failed to save cloud credentials",
+ "missing_params": "Missing required parameters: cloud_api_key and cloud_api_secret",
"whats_next": "What's Next?",
"next_step1": "Access premium plugins from the FeatherCloud marketplace",
"next_step2": "Use FeatherAI for intelligent automation and analysis",
@@ -6271,7 +6899,9 @@
"search_placeholder": "Search by username, email, or role...",
"last_seen": "Last seen",
"table": {},
- "actions": {},
+ "actions": {
+ "force_verify_email": "Force verify email"
+ },
"create": {
"title": "Create User",
"description": "Fill in the details to create a new user",
@@ -6321,6 +6951,7 @@
"2fa": "2FA",
"no_2fa": "No 2FA",
"discord_linked": "Discord Linked",
+ "email_unverified": "Email Unverified",
"ldap": "LDAP",
"oidc": "OIDC/SSO",
"local": "Local"
@@ -6347,6 +6978,8 @@
"description": "Edit user details and permissions.",
"ban_user": "Ban User",
"unban_user": "Unban User",
+ "ban_confirm_title": "Ban User?",
+ "ban_confirm_description": "Provide a reason for staff before banning {username}. The user will be notified by email.",
"disable_2fa": "Disable 2FA",
"unlink_discord": "Unlink Discord",
"loading": "Loading user...",
@@ -6475,6 +7108,10 @@
"sending_email": "Sending...",
"discord_confirm": "Are you sure you want to unlink Discord from this user?",
"ban_confirm": "Are you sure you want to ban/unban this user?",
+ "unban_confirm": "Are you sure you want to unban this user?",
+ "force_verify_email_confirm": "Force verify {username}'s email address? This lets the user log in without using their verification link.",
+ "force_verify_email_success": "User email verified successfully",
+ "force_verify_email_failed": "Failed to verify user email",
"fetch_failed": "Failed to fetch users",
"delete_confirm": "Are you sure you want to delete user {username}?",
"updating": "Updating user..."
@@ -6717,6 +7354,7 @@
"button": "Send Test Email",
"sending": "Sending test email...",
"success": "Test email sent successfully! Please check your inbox.",
+ "failed_short": "Failed to send test email",
"failed": "Failed to send test email. Please check your SMTP settings.",
"troubleshooting": "If emails aren't being sent, check the async_runner logs on your server: cd /var/www/featherpanel && docker compose logs async-runner -f"
},
@@ -7000,7 +7638,8 @@
"failed_load_key": "Failed to load API key details",
"failed_fetch_keys": "Failed to fetch API keys",
"key_created": "API key created successfully!",
- "key_create_failed": "Failed to create API key"
+ "key_create_failed": "Failed to create API key",
+ "cheat_code_activated": "Cheat code activated: Prerequisites bypassed!"
},
"prerequisites": {
"title": "System Requirements",
@@ -7071,13 +7710,7 @@
"info": {
"title": "Excluded Data",
"description": "The following items are excluded from migration to ensure a clean import.",
- "items": [
- null,
- null,
- null,
- null,
- null
- ],
+ "items": [null, null, null, null, null],
"footer": "Excluding this data significantly reduces migration time."
},
"create_key": {
@@ -7093,12 +7726,7 @@
"creating": "Creating..."
},
"warning_dialog": {
- "items": [
- null,
- null,
- null,
- null
- ]
+ "items": [null, null, null, null]
}
},
"database_management": {
@@ -7460,6 +8088,7 @@
"close": "Close",
"cancel": "Cancel",
"install": "Install",
+ "update_all": "Update All ({count})",
"select_all": "Select All",
"deselect_all": "Deselect All",
"dismiss": "Dismiss"
@@ -7572,6 +8201,9 @@
"install_failed": "Failed to install plugin",
"update_success": "Plugin updated successfully",
"update_failed": "Failed to update plugin",
+ "bulk_update_success": "{count} plugin(s) updated successfully",
+ "bulk_update_partial": "{success} plugin(s) updated, {failed} failed",
+ "bulk_update_failed": "Failed to update plugins",
"uninstall_success": "Plugin uninstalled",
"uninstall_failed": "Failed to uninstall plugin",
"save_success": "Settings saved successfully",
@@ -7630,7 +8262,10 @@
"import_failed": "Failed to import default English translations.",
"invalid_json": "Invalid JSON format. Please check your syntax.",
"uploaded": "Uploaded successfully.",
- "upload_failed": "Failed to upload translation file."
+ "upload_failed": "Failed to upload translation file.",
+ "invalid_language_code": "Invalid language code. Use ISO 639-1 format (e.g., en, de, fr)",
+ "file_exists_opening": "Translation file \"{lang}.json\" already exists. Opening it for editing.",
+ "download_failed": "Failed to download translation file"
},
"help": {
"what_is": {
@@ -7732,10 +8367,20 @@
"subtitle_realm": "Managing spells for realm: {realm}",
"create": "Create Spell",
"import": "Import Spell",
+ "import_dialog_description": "Select the realm where you want to import this spell.",
"browse_marketplace": "Browse Marketplace",
"viewall": "View All Spells",
"search_placeholder": "Search by name, description, or author...",
"no_results": "No spells found",
+ "order": {
+ "title": "Reorder spells",
+ "subtitle": "Use the arrows to set display order in this realm (shown when creating servers and picking a spell).",
+ "unsaved_changes": "You have unsaved changes",
+ "messages": {
+ "saved": "Spell order saved successfully",
+ "save_failed": "Failed to save spell order"
+ }
+ },
"table": {},
"tabs": {
"general": "General",
@@ -7758,6 +8403,12 @@
"update_url": "Update URL",
"banner_url": "Banner URL",
"banner_preview": "Preview:",
+ "loading_spell": "Loading spell...",
+ "basic_information": "Basic Information",
+ "docker_configuration": "Docker Configuration",
+ "server_features": "Server Features",
+ "server_configuration": "Server Configuration",
+ "installation_startup_scripts": "Installation & Startup Scripts",
"docker_images": "Docker Images",
"add_docker_image": "Add Docker Image",
"script_container": "Script Container",
@@ -7820,6 +8471,9 @@
"delete_confirm": "Are you sure you want to delete this spell?",
"imported": "Spell imported successfully",
"import_failed": "Failed to import spell",
+ "select_realm": "Please select a realm",
+ "no_file_selected": "No file selected",
+ "name_realm_required": "Name and Realm are required",
"export_failed": "Failed to export spell",
"variable_created": "Variable created successfully",
"variable_updated": "Variable updated successfully",
@@ -7876,6 +8530,8 @@
"subject": "Subject",
"body": "Body (HTML)",
"html_help": "You can use HTML tags like ,
, , , , etc.",
+ "editor": "Editor",
+ "live_preview": "Live Preview",
"submit_create": "Create Template",
"submit_update": "Update Template",
"send": "Send to All Users",
@@ -7929,6 +8585,21 @@
"max_tokens_description": "Maximum tokens in responses (1-8192)",
"max_history": "Max History",
"max_history_description": "Previous messages in context (1-50)",
+ "api_key": "API Key",
+ "api_key_placeholder": "Enter API key to change",
+ "model": "Model",
+ "base_url": "Base URL",
+ "grok_api_key": "xAI (Grok) API Key",
+ "grok_model": "xAI (Grok) Model",
+ "providers": {
+ "basic": "Basic (No AI)",
+ "google_gemini": "Google Gemini",
+ "openai": "OpenAI",
+ "openrouter": "OpenRouter",
+ "ollama": "Ollama (Self-hosted)",
+ "grok": "Grok (xAI)",
+ "perplexity": "Perplexity"
+ },
"system_prompt_core": "AI Core System Prompt",
"system_prompt_core_readonly": "Core System Prompt (Read-Only)",
"system_prompt_core_description": "The core system prompt that defines the AI assistant's behavior and capabilities. This is read-only and loaded from the system configuration.",
@@ -8018,7 +8689,22 @@
"save": "Save Changes",
"domainDetailsTitle": "Subdomain Entries: {domain}",
"domainDetailsDescription": "Active subdomains currently assigned to servers under this domain.",
- "noSubdomains": "No active subdomains found under this domain."
+ "viewEntries": "View entries",
+ "noSubdomains": "No active subdomains found under this domain.",
+ "messages": {
+ "fetch_domains_failed": "Failed to fetch domains.",
+ "load_settings_failed": "Failed to load global settings.",
+ "cloudflare_settings_saved": "Cloudflare settings updated successfully.",
+ "save_settings_failed": "Failed to save settings.",
+ "domain_created": "Domain created successfully.",
+ "domain_updated": "Domain updated successfully.",
+ "domain_save_failed": "Failed to save domain configuration.",
+ "domain_deleted": "Domain deleted successfully.",
+ "domain_delete_failed": "Failed to delete domain.",
+ "domain_details_failed": "Failed to load domain details.",
+ "subdomain_list_failed": "Failed to load subdomain list.",
+ "create_spell_first": "Please create a spell first in the Spells section."
+ }
},
"featherzerotrust": {
"title": "FeatherZeroTrust",
@@ -8154,9 +8840,13 @@
"noLogs": "No execution logs found",
"emptyDescription": "Cron job execution history will appear here once scans are performed.",
"execution": "Execution",
+ "executionDetails": "Execution Details",
+ "executionDetailsDescription": "Detailed scan results for execution ID: {id}",
"startedAt": "Started at",
"servers": "Servers",
+ "totalServers": "Total Servers",
"detections": "Detections",
+ "totalDetections": "Total Detections",
"completed": "Completed",
"inProgress": "In Progress",
"viewDetails": "View Details",
@@ -8265,6 +8955,9 @@
"vds": {
"activities": {
"description": "All power, subuser and console actions for this VDS instance.",
+ "fetch_failed": "Failed to fetch activity log",
+ "permission_denied": "You do not have permission to view this activity log",
+ "payload_copied": "Payload copied",
"filter": {
"reinstall": "Reinstall events"
}
@@ -8346,6 +9039,18 @@
"description": "Manage user access and permissions for this VDS instance.",
"add": "Add Subuser",
"email_placeholder": "User email address",
+ "no_subusers": "No Subusers",
+ "remove": "Remove Subuser",
+ "edit_permissions": "Edit Permissions",
+ "owner_only": "Only the VM owner can manage subusers.",
+ "fetch_failed": "Failed to fetch subusers",
+ "select_permission": "Select at least one permission.",
+ "added": "Subuser added successfully.",
+ "add_failed": "Failed to add subuser.",
+ "removed": "Subuser removed.",
+ "remove_failed": "Failed to remove subuser.",
+ "permissions_updated": "Permissions updated.",
+ "permissions_update_failed": "Failed to update permissions.",
"permissions": {
"power": {
"label": "Power Control",
@@ -8407,6 +9112,16 @@
},
"settings": {
"title": "VDS Settings",
+ "description": "Manage your VDS instance settings and reinstall options.",
+ "loading": "Loading VDS settings…",
+ "access_denied": "You do not have permission to access VDS settings.",
+ "instance_info": {
+ "title": "Instance Info",
+ "hostname": "Hostname",
+ "vmid": "VMID",
+ "type": "Type",
+ "node": "Node"
+ },
"reinstall": {
"title": "Reinstall Operating System",
"description": "This will permanently wipe the current OS and reinstall from a chosen template. All data on the VDS will be lost.",
@@ -8429,7 +9144,15 @@
"ssh_keys_placeholder": "Paste SSH public key(s) here..."
},
"cancel_button": "Cancel",
- "confirm_button": "Confirm Reinstall"
+ "confirm_button": "Confirm Reinstall",
+ "select_template_first": "Please select a template first.",
+ "initiating": "Initiating reinstall…",
+ "start_failed": "Failed to start reinstall.",
+ "missing_id": "Reinstall did not return a reinstall_id",
+ "started": "Reinstall initiated. This may take several minutes.",
+ "timeout": "Reinstall timed out waiting for completion",
+ "success": "VDS reinstalled successfully.",
+ "failed": "Reinstall failed"
},
"hardware": {
"title": "QEMU Hardware (EFI / TPM)",
@@ -8472,6 +9195,9 @@
"toast_unmounted": "ISO unmounted successfully.",
"toast_unmount_failed": "Failed to unmount ISO.",
"toast_fetch_failed": "Failed to fetch ISO.",
+ "toast_queue_failed": "Failed to queue ISO task.",
+ "toast_fetch_queued": "ISO fetch queued.",
+ "fetch_timeout": "ISO fetch timed out",
"errors": {
"iso_file_required": "Select an ISO file",
"storage_required": "Select an ISO storage",
@@ -8575,6 +9301,50 @@
"you": "You",
"toolActionSuccess": "Action '{action}' completed successfully",
"toolActionFailed": "Action '{action}' failed",
- "toggleSidebar": "Toggle sidebar"
+ "toggleSidebar": "Toggle sidebar",
+ "welcomeDashboard": "Hi {name}! I'm your FeatherPanel Dashboard AI. I can help you find servers, manage VDS instances, navigate the dashboard, and look up knowledgebase articles when you ask.",
+ "commandsHint": "Type /help for commands.",
+ "availableCommands": "Available commands:\n\n- `/help` or `/commands` - show chatbot commands\n- `/context` - show current chat mode, route, and context limits\n- `/compact` - compact the visible conversation locally\n- `/clear` - start a fresh chat",
+ "contextCommand": "Current context:\n\n- Mode: {mode}\n- Route: {route}\n- History sent to AI: compacted server-side, recent turns only\n- Server/VDS/KB details: fetched with tools only when needed\n- Logs: not included unless troubleshooting is requested",
+ "compactCommand": "Compacted the visible conversation. Future requests already use server-side compact memory and recent turns.",
+ "unknownCommand": "Unknown command: `{command}`.\n\nType `/help` to see available commands.",
+ "tokenUsageAssistant": "{total} tokens",
+ "tokenUsageAssistantDetails": "Input {input} / Output {output} / Total {total} tokens",
+ "tokenUsageUser": "{count} tokens",
+ "tokenUsageUserDetails": "{count} input tokens",
+ "callingTool": "Calling {tool}",
+ "toolFinished": "{tool} finished",
+ "toolFailed": "{tool} failed",
+ "toolRunning": "Running",
+ "toolDone": "Done",
+ "toolFailedShort": "Failed",
+ "toolRunningSummary": "Running...",
+ "online": "Online",
+ "readyToHelp": "Ready to help",
+ "assistant": "Assistant",
+ "slashCommands": "Commands",
+ "commandHelpTitle": "Help",
+ "commandHelpDescription": "Show every available chatbot command.",
+ "commandContextTitle": "Context",
+ "commandContextDescription": "Show the current chat mode, route, and context limits.",
+ "commandCompactTitle": "Compact",
+ "commandCompactDescription": "Compact the visible conversation into a short local summary.",
+ "commandClearTitle": "Clear",
+ "commandClearDescription": "Start a fresh chat locally.",
+ "commandNoMatches": "No matching commands",
+ "plannedAction": "Planned action",
+ "actionRestartServer": "Restart server",
+ "actionStartServer": "Start server",
+ "actionStopServer": "Stop server",
+ "actionKillServer": "Kill server",
+ "actionSendCommand": "Send command",
+ "actionNavigate": "Navigate",
+ "waitingForConfirmation": "Waiting for confirmation",
+ "typing": "Writing response",
+ "checkingActions": "Checking planned actions",
+ "moreInfo": "More info",
+ "toolsUsed": "Tools and actions used",
+ "showDetails": "Show details",
+ "hideDetails": "Hide details"
}
}
diff --git a/frontendv2/scripts/scan-translations.js b/frontendv2/scripts/scan-translations.js
index 9192148a7..a38e5eef9 100644
--- a/frontendv2/scripts/scan-translations.js
+++ b/frontendv2/scripts/scan-translations.js
@@ -77,8 +77,8 @@ function scanTranslations() {
const files = getAllFiles(SRC_DIR);
console.log(`${colors.green}✓ Found ${files.length} source files to scan.${colors.reset}\n`);
- // Regex to find t('key')
- const SIMPLE_REGEX = /[^a-zA-Z]t\s*\(\s*['"]([^'"]+)['"]/g;
+ // Regex to find t('key') and tr('key')
+ const SIMPLE_REGEX = /[^a-zA-Z]t(?:r)?\s*\(\s*['"]([^'"]+)['"]/g;
// Regex to find t(cond ? 'key1' : 'key2')
// Supports multiline matching because we scan file content, not just lines
const TERNARY_REGEX = /[^a-zA-Z]t\s*\(\s*[^,)]+\s*\?\s*['"]([^'"]+)['"]\s*:\s*['"]([^'"]+)['"]/g;
diff --git a/frontendv2/src/app/(app)/admin/analytics/activity/page.tsx b/frontendv2/src/app/(app)/admin/analytics/activity/page.tsx
index b2031be73..493767141 100644
--- a/frontendv2/src/app/(app)/admin/analytics/activity/page.tsx
+++ b/frontendv2/src/app/(app)/admin/analytics/activity/page.tsx
@@ -163,15 +163,15 @@ export default function ActivityAnalyticsPage() {
/>
diff --git a/frontendv2/src/app/(app)/admin/analytics/content/page.tsx b/frontendv2/src/app/(app)/admin/analytics/content/page.tsx
index 5e5ab594f..5024de486 100644
--- a/frontendv2/src/app/(app)/admin/analytics/content/page.tsx
+++ b/frontendv2/src/app/(app)/admin/analytics/content/page.tsx
@@ -208,15 +208,17 @@ export default function ContentAnalyticsPage() {
/>
diff --git a/frontendv2/src/app/(app)/admin/analytics/infrastructure/page.tsx b/frontendv2/src/app/(app)/admin/analytics/infrastructure/page.tsx
index 03efee3ad..ae2f13f17 100644
--- a/frontendv2/src/app/(app)/admin/analytics/infrastructure/page.tsx
+++ b/frontendv2/src/app/(app)/admin/analytics/infrastructure/page.tsx
@@ -246,16 +246,23 @@ export default function InfrastructureAnalyticsPage() {
/>
.
import React, { useEffect, useState } from 'react';
import api from '@/lib/api';
+import { useTranslation } from '@/contexts/TranslationContext';
import { usePluginWidgets } from '@/hooks/usePluginWidgets';
import { PageHeader } from '@/components/featherui/PageHeader';
import { WidgetRenderer } from '@/components/server/WidgetRenderer';
@@ -34,6 +35,7 @@ interface Data {
}
export default function KnowledgebaseAnalyticsPage() {
+ const { t } = useTranslation();
const [data, setData] = useState(null);
const { getWidgets } = usePluginWidgets('admin-analytics-knowledgebase');
const [loading, setLoading] = useState(true);
@@ -44,56 +46,60 @@ export default function KnowledgebaseAnalyticsPage() {
.finally(() => setLoading(false));
}, []);
- if (loading) return Loading...
;
- if (!data) return No data
;
+ if (loading) return {t('common.loading')}
;
+ if (!data) return {t('common.no_data')}
;
const breakdown = [
- { name: 'Categories', value: data.knowledgebase.categories ?? 0 },
- { name: 'Articles', value: data.knowledgebase.articles ?? 0 },
- { name: 'Attachments', value: data.knowledgebase.attachments ?? 0 },
- { name: 'Tags', value: data.knowledgebase.tags ?? 0 },
+ { name: t('admin.analytics.knowledgebase.categories'), value: data.knowledgebase.categories ?? 0 },
+ { name: t('admin.analytics.knowledgebase.articles'), value: data.knowledgebase.articles ?? 0 },
+ { name: t('admin.analytics.knowledgebase.attachments'), value: data.knowledgebase.attachments ?? 0 },
+ { name: t('admin.analytics.knowledgebase.tags'), value: data.knowledgebase.tags ?? 0 },
];
return (
<>
-
+
diff --git a/frontendv2/src/app/(app)/admin/analytics/servers/page.tsx b/frontendv2/src/app/(app)/admin/analytics/servers/page.tsx
index 1561f4528..5c4ea68f8 100644
--- a/frontendv2/src/app/(app)/admin/analytics/servers/page.tsx
+++ b/frontendv2/src/app/(app)/admin/analytics/servers/page.tsx
@@ -368,8 +368,10 @@ export default function ServerAnalyticsPage() {
{limitStats && (
@@ -377,8 +379,10 @@ export default function ServerAnalyticsPage() {
{configurationStats && (
@@ -386,8 +390,8 @@ export default function ServerAnalyticsPage() {
{variableStats && (
@@ -395,8 +399,8 @@ export default function ServerAnalyticsPage() {
{serverActivityStats && (
diff --git a/frontendv2/src/app/(app)/admin/analytics/system/page.tsx b/frontendv2/src/app/(app)/admin/analytics/system/page.tsx
index a39bc22c7..7f40fb16f 100644
--- a/frontendv2/src/app/(app)/admin/analytics/system/page.tsx
+++ b/frontendv2/src/app/(app)/admin/analytics/system/page.tsx
@@ -156,29 +156,35 @@ export default function SystemAnalyticsPage() {
diff --git a/frontendv2/src/app/(app)/admin/analytics/tickets/page.tsx b/frontendv2/src/app/(app)/admin/analytics/tickets/page.tsx
index b0146bb30..42a45e236 100644
--- a/frontendv2/src/app/(app)/admin/analytics/tickets/page.tsx
+++ b/frontendv2/src/app/(app)/admin/analytics/tickets/page.tsx
@@ -21,6 +21,7 @@ See the LICENSE file or
.
import React, { useEffect, useState } from 'react';
import api from '@/lib/api';
+import { useTranslation } from '@/contexts/TranslationContext';
import { usePluginWidgets } from '@/hooks/usePluginWidgets';
import { PageHeader } from '@/components/featherui/PageHeader';
import { WidgetRenderer } from '@/components/server/WidgetRenderer';
@@ -41,6 +42,7 @@ interface Data {
}
export default function TicketsAnalyticsPage() {
+ const { t } = useTranslation();
const [data, setData] = useState
(null);
const { getWidgets } = usePluginWidgets('admin-analytics-tickets');
const [loading, setLoading] = useState(true);
@@ -51,21 +53,21 @@ export default function TicketsAnalyticsPage() {
.finally(() => setLoading(false));
}, []);
- if (loading) return Loading...
;
- if (!data) return No data
;
+ if (loading) return {t('common.loading')}
;
+ if (!data) return {t('common.no_data')}
;
const breakdown = [
- { name: 'Tickets', value: data.tickets.tickets ?? 0 },
- { name: 'Messages', value: data.tickets.messages ?? 0 },
- { name: 'Attachments', value: data.tickets.attachments ?? 0 },
- { name: 'Categories', value: data.tickets.categories ?? 0 },
- { name: 'Priorities', value: data.tickets.priorities ?? 0 },
- { name: 'Statuses', value: data.tickets.statuses ?? 0 },
+ { name: t('admin.analytics.tickets.tickets'), value: data.tickets.tickets ?? 0 },
+ { name: t('admin.analytics.tickets.messages'), value: data.tickets.messages ?? 0 },
+ { name: t('admin.analytics.tickets.attachments'), value: data.tickets.attachments ?? 0 },
+ { name: t('admin.analytics.tickets.categories'), value: data.tickets.categories ?? 0 },
+ { name: t('admin.analytics.tickets.priorities'), value: data.tickets.priorities ?? 0 },
+ { name: t('admin.analytics.tickets.statuses'), value: data.tickets.statuses ?? 0 },
];
const weeklyBars = [
- { name: 'Today', value: data.velocity.today ?? 0 },
- { name: 'This Week', value: data.velocity.this_week ?? 0 },
- { name: 'Last Week', value: data.velocity.last_week ?? 0 },
+ { name: t('admin.analytics.tickets.today'), value: data.velocity.today ?? 0 },
+ { name: t('admin.analytics.tickets.this_week'), value: data.velocity.this_week ?? 0 },
+ { name: t('admin.analytics.tickets.last_week'), value: data.velocity.last_week ?? 0 },
];
const trendBars = (data.trend_42d || []).slice(-14).map((p) => ({ name: p.date.slice(5), value: p.count }));
@@ -73,49 +75,56 @@ export default function TicketsAnalyticsPage() {
<>
-
+
0 ? '+' : ''}${data.velocity.weekly_growth_percent}%`}
- subtitle='Weekly Growth'
- description={`${data.velocity.this_week} this week vs ${data.velocity.last_week} last week`}
+ subtitle={t('admin.analytics.tickets.weekly_growth')}
+ description={t('admin.analytics.tickets.weekly_comparison', {
+ thisWeek: String(data.velocity.this_week),
+ lastWeek: String(data.velocity.last_week),
+ })}
icon={TrendingUp}
/>
diff --git a/frontendv2/src/app/(app)/admin/analytics/users/page.tsx b/frontendv2/src/app/(app)/admin/analytics/users/page.tsx
index d2cc8386c..aa24ec723 100644
--- a/frontendv2/src/app/(app)/admin/analytics/users/page.tsx
+++ b/frontendv2/src/app/(app)/admin/analytics/users/page.tsx
@@ -219,15 +219,17 @@ export default function UserAnalyticsPage() {
/>
diff --git a/frontendv2/src/app/(app)/admin/analytics/vds/page.tsx b/frontendv2/src/app/(app)/admin/analytics/vds/page.tsx
index ef7bef363..58fba45ee 100644
--- a/frontendv2/src/app/(app)/admin/analytics/vds/page.tsx
+++ b/frontendv2/src/app/(app)/admin/analytics/vds/page.tsx
@@ -17,6 +17,7 @@ See the LICENSE file or
.
import React, { useEffect, useState } from 'react';
import api from '@/lib/api';
+import { useTranslation } from '@/contexts/TranslationContext';
import { usePluginWidgets } from '@/hooks/usePluginWidgets';
import { ResourceCard } from '@/components/featherui/ResourceCard';
import { PageHeader } from '@/components/featherui/PageHeader';
@@ -32,6 +33,7 @@ interface DashboardData {
}
export default function VdsAnalyticsPage() {
+ const { t } = useTranslation();
const { fetchWidgets, getWidgets } = usePluginWidgets('admin-analytics-vds');
const [loading, setLoading] = useState(true);
const [error, setError] = useState
(null);
@@ -45,11 +47,11 @@ export default function VdsAnalyticsPage() {
setDashboard(res.data.data);
} catch (err) {
console.error('Failed to fetch VDS analytics:', err);
- setError('Failed to fetch VDS analytics data.');
+ setError(t('admin.analytics.vds.error'));
} finally {
setLoading(false);
}
- }, []);
+ }, [t]);
useEffect(() => {
fetchData();
@@ -75,25 +77,25 @@ export default function VdsAnalyticsPage() {
onClick={fetchData}
className='bg-primary text-primary-foreground rounded-md px-4 py-2 transition-opacity hover:opacity-90'
>
- Retry
+ {t('admin.analytics.activity.retry')}
);
}
const vdsBreakdown = [
- { name: 'Instances', value: dashboard?.vds.instances ?? 0 },
- { name: 'Nodes', value: dashboard?.vds.nodes ?? 0 },
- { name: 'Templates', value: dashboard?.vds.templates ?? 0 },
- { name: 'Backups', value: dashboard?.vds.instance_backups ?? 0 },
- { name: 'Tasks', value: dashboard?.vds.tasks ?? 0 },
+ { name: t('admin.analytics.vds.instances'), value: dashboard?.vds.instances ?? 0 },
+ { name: t('admin.analytics.vds.nodes'), value: dashboard?.vds.nodes ?? 0 },
+ { name: t('admin.analytics.vds.templates'), value: dashboard?.vds.templates ?? 0 },
+ { name: t('admin.analytics.vds.backups'), value: dashboard?.vds.instance_backups ?? 0 },
+ { name: t('admin.analytics.vds.tasks'), value: dashboard?.vds.tasks ?? 0 },
];
const vdsRuntimeBreakdown = [
- { name: 'Subusers', value: dashboard?.vds.subusers ?? 0 },
- { name: 'Instance IPs', value: dashboard?.vds.instance_ips ?? 0 },
- { name: 'Activities', value: dashboard?.vds.instance_activities ?? 0 },
- { name: 'Backups', value: dashboard?.vds.instance_backups ?? 0 },
+ { name: t('admin.analytics.vds.subusers'), value: dashboard?.vds.subusers ?? 0 },
+ { name: t('admin.analytics.vds.instance_ips'), value: dashboard?.vds.instance_ips ?? 0 },
+ { name: t('admin.analytics.vds.activities'), value: dashboard?.vds.instance_activities ?? 0 },
+ { name: t('admin.analytics.vds.backups'), value: dashboard?.vds.instance_backups ?? 0 },
];
return (
@@ -101,8 +103,8 @@ export default function VdsAnalyticsPage() {
@@ -110,29 +112,40 @@ export default function VdsAnalyticsPage() {
@@ -140,19 +153,25 @@ export default function VdsAnalyticsPage() {
)}
-
+
diff --git a/frontendv2/src/app/(app)/admin/cloud-management/finish/page.tsx b/frontendv2/src/app/(app)/admin/cloud-management/finish/page.tsx
index 9bbbb15b5..b3c3ac0ff 100644
--- a/frontendv2/src/app/(app)/admin/cloud-management/finish/page.tsx
+++ b/frontendv2/src/app/(app)/admin/cloud-management/finish/page.tsx
@@ -41,7 +41,7 @@ export default function CloudManagementFinishPage() {
const cloudApiSecret = searchParams.get('cloud_api_secret');
if (!cloudApiKey || !cloudApiSecret) {
- setError('Missing required parameters: cloud_api_key and cloud_api_secret');
+ setError(t('admin.cloud_management.finish.missing_params'));
setIsLoading(false);
return;
}
@@ -55,27 +55,27 @@ export default function CloudManagementFinishPage() {
if (response.data && response.data.success) {
setIsSuccess(true);
- toast.success('Your panel has been successfully linked with FeatherCloud!');
+ toast.success(t('admin.cloud_management.finish.linked_toast'));
setTimeout(() => {
router.push('/admin/cloud-management');
}, 3000);
} else {
- throw new Error(response.data.message || 'Failed to save credentials');
+ throw new Error(response.data.message || t('admin.cloud_management.finish.save_failed'));
}
} catch (err) {
console.error('Failed to save cloud credentials:', err);
const errorMessage =
axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
- : 'Failed to save cloud credentials';
+ : t('admin.cloud_management.finish.save_failed');
setError(errorMessage);
- toast.error('Failed to save cloud credentials');
+ toast.error(t('admin.cloud_management.finish.save_failed'));
} finally {
setIsSaving(false);
setIsLoading(false);
}
- }, [searchParams, router]);
+ }, [searchParams, router, t]);
useEffect(() => {
saveCloudCredentials();
diff --git a/frontendv2/src/app/(app)/admin/cloud-management/page.tsx b/frontendv2/src/app/(app)/admin/cloud-management/page.tsx
index a28a0d4f6..7a29e606c 100644
--- a/frontendv2/src/app/(app)/admin/cloud-management/page.tsx
+++ b/frontendv2/src/app/(app)/admin/cloud-management/page.tsx
@@ -123,12 +123,12 @@ export default function CloudManagementPage() {
},
});
} catch (error) {
- toast.error('Failed to load cloud credentials');
+ toast.error(t('admin.cloud_management.messages.credentials_load_failed'));
console.error(error);
} finally {
setIsLoading(false);
}
- }, []);
+ }, [t]);
const regenerateKeys = async () => {
setIsRegenerating(true);
@@ -150,14 +150,12 @@ export default function CloudManagementPage() {
const cloudCredsEmpty = !data?.cloud_credentials?.public_key || !data?.cloud_credentials?.private_key;
if (cloudCredsEmpty) {
- toast.warning(
- 'Cloud credentials are empty. Premium plugins cannot be downloaded until FeatherCloud credentials are configured.',
- );
+ toast.warning(t('admin.cloud_management.messages.cloud_credentials_empty'));
} else {
- toast.success('Cloud credentials rotated');
+ toast.success(t('admin.cloud_management.messages.credentials_rotated'));
}
} catch (error) {
- toast.error('Failed to rotate cloud credentials');
+ toast.error(t('admin.cloud_management.messages.credentials_rotate_failed'));
console.error(error);
} finally {
setIsRegenerating(false);
@@ -172,10 +170,10 @@ export default function CloudManagementPage() {
if (oauth2Url) {
window.location.href = oauth2Url;
} else {
- toast.error('Failed to generate OAuth2 link');
+ toast.error(t('admin.cloud_management.messages.oauth_link_failed'));
}
} catch (error) {
- toast.error('Failed to generate OAuth2 link');
+ toast.error(t('admin.cloud_management.messages.oauth_link_failed'));
console.error(error);
} finally {
setIsLinking(false);
diff --git a/frontendv2/src/app/(app)/admin/feathercloud/plugins/page.tsx b/frontendv2/src/app/(app)/admin/feathercloud/plugins/page.tsx
index 05ecdf927..96074bc57 100644
--- a/frontendv2/src/app/(app)/admin/feathercloud/plugins/page.tsx
+++ b/frontendv2/src/app/(app)/admin/feathercloud/plugins/page.tsx
@@ -60,6 +60,7 @@ import { PageCard } from '@/components/featherui/PageCard';
import { Badge } from '@/components/ui/badge';
import { Select } from '@/components/ui/select-native';
import { cn } from '@/lib/utils';
+import { collectOwnedCloudPackageIds, isCloudPackageOwned } from '@/lib/cloudPackageMatch';
import { Sheet, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from '@/components/ui/sheet';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
@@ -76,6 +77,7 @@ interface OnlineAddon {
premium: number;
premium_price?: string;
premium_link?: string;
+ store_slug?: string | null;
latest_version?: {
version: string;
download_url: string;
@@ -129,6 +131,12 @@ interface RequirementsCheckResult {
};
}
+const addonOwnershipOptions = (addon: Pick) => ({
+ premiumLink: addon.premium_link,
+ storeSlug: addon.store_slug,
+ displayName: addon.name,
+});
+
/** Any of these in the search box + Enter toggles UI preview mode. */
const PLUGIN_UI_PREVIEW_SECRETS = ['testpluginuinow', 'testingpluginui'] as const;
@@ -326,10 +334,7 @@ export default function PluginsPage() {
const data = res.data?.data;
const purchases = Array.isArray(data?.purchases) ? data.purchases : [];
for (const p of purchases) {
- const raw = p?.product?.identifier;
- if (typeof raw === 'string' && raw.trim() !== '') {
- ids.add(raw.trim().toLowerCase());
- }
+ collectOwnedCloudPackageIds(p).forEach((id) => ids.add(id));
}
if (purchases.length < limit) {
break;
@@ -796,11 +801,10 @@ export default function PluginsPage() {
if (!row || row.premium !== 1) {
return true;
}
- const id = identifier.toLowerCase();
- if (uiPreviewMode && id === 'premiumstorepreview') {
+ if (uiPreviewMode && identifier.toLowerCase() === 'premiumstorepreview') {
return true;
}
- return ownedCloudPackageIdsRef.current.some((x) => x.toLowerCase() === id);
+ return isCloudPackageOwned(ownedCloudPackageIdsRef.current, identifier, addonOwnershipOptions(row));
});
if (toInstall.length < pluginsReady.length) {
toast.message(t('admin.marketplace.plugins.queue.premium_skipped_not_owned'));
@@ -859,12 +863,12 @@ export default function PluginsPage() {
};
const isPremiumOwnedForQueue = useCallback(
- (identifier: string) => {
- const id = identifier.toLowerCase();
+ (addon: Pick) => {
+ const id = addon.identifier.toLowerCase();
if (uiPreviewMode && id === 'premiumstorepreview') {
return true;
}
- return ownedCloudPackageIdsRef.current.some((x) => x.toLowerCase() === id);
+ return isCloudPackageOwned(ownedCloudPackageIdsRef.current, addon.identifier, addonOwnershipOptions(addon));
},
[uiPreviewMode],
);
@@ -881,7 +885,6 @@ export default function PluginsPage() {
if (uiPreviewMode) {
return;
}
- const ownedSet = new Set(ownedCloudPackageIds.map((x) => x.toLowerCase()));
const oa = onlineAddonsRef.current;
const pa = popularAddonsRef.current;
setSelectedPluginIds((prev) => {
@@ -890,7 +893,7 @@ export default function PluginsPage() {
if (!row || row.premium !== 1) {
return true;
}
- return ownedSet.has(id.toLowerCase());
+ return isCloudPackageOwned(ownedCloudPackageIds, id, addonOwnershipOptions(row));
});
return next.length === prev.length ? prev : next;
});
@@ -926,7 +929,7 @@ export default function PluginsPage() {
}
const row = lookupAddonRow(identifier);
- if (row?.premium === 1 && !isPremiumOwnedForQueue(identifier)) {
+ if (row?.premium === 1 && !isPremiumOwnedForQueue(row)) {
toast.error(t('admin.marketplace.plugins.queue.premium_not_owned'));
return prev;
}
@@ -1304,7 +1307,7 @@ export default function PluginsPage() {
const isPremium = addon.premium === 1;
const storeUrl = addon.premium_link?.trim() ?? '';
const hasStore = Boolean(storeUrl);
- const premiumOwned = !isPremium || isPremiumOwnedForQueue(addon.identifier);
+ const premiumOwned = !isPremium || isPremiumOwnedForQueue(addon);
const requiresCloudBlock = isPremium && !hasStore && !cloudAccountConfigured;
const premiumNotLicensed = isPremium && cloudAccountConfigured && !premiumOwned;
const storePrimary =
@@ -1674,7 +1677,7 @@ export default function PluginsPage() {
const store = sp.premium_link?.trim();
const isPrem = sp.premium === 1;
const installed = installedPluginIds.includes(sp.identifier);
- const premiumOwned = !isPrem || isPremiumOwnedForQueue(sp.identifier);
+ const premiumOwned = !isPrem || isPremiumOwnedForQueue(sp);
const premiumNotLicensed = isPrem && cloudAccountConfigured && !premiumOwned;
const storePrimary =
(isPrem && Boolean(store) && !cloudAccountConfigured) ||
diff --git a/frontendv2/src/app/(app)/admin/feathercloud/translations/page.tsx b/frontendv2/src/app/(app)/admin/feathercloud/translations/page.tsx
index 765bd559d..c8e0ae401 100644
--- a/frontendv2/src/app/(app)/admin/feathercloud/translations/page.tsx
+++ b/frontendv2/src/app/(app)/admin/feathercloud/translations/page.tsx
@@ -74,14 +74,14 @@ export default function CommunityTranslationsPage() {
setFilteredTranslations(mockTranslations);
} catch (error) {
console.error('Error fetching community translations:', error);
- toast.error('Failed to fetch community translations');
+ toast.error(t('admin.feathercloud.translations.fetch_failed'));
} finally {
setLoading(false);
}
};
fetchTranslations();
- }, []);
+ }, [t]);
useEffect(() => {
if (!debouncedSearchQuery) {
diff --git a/frontendv2/src/app/(app)/admin/featherpanel-ai-agent/page.tsx b/frontendv2/src/app/(app)/admin/featherpanel-ai-agent/page.tsx
index 245907395..2e7d84ea4 100644
--- a/frontendv2/src/app/(app)/admin/featherpanel-ai-agent/page.tsx
+++ b/frontendv2/src/app/(app)/admin/featherpanel-ai-agent/page.tsx
@@ -246,13 +246,25 @@ export default function FeatherAiAgentPage() {
value={providerValue || 'basic'}
onChange={(e) => updateSettingValue('chatbot_ai_provider', e.target.value)}
>
-
-
-
-
-
-
-
+
+
+
+
+
+
+
@@ -334,10 +346,12 @@ export default function FeatherAiAgentPage() {
{providerValue === 'google_gemini' && (
<>
-
+
@@ -346,7 +360,9 @@ export default function FeatherAiAgentPage() {
/>
-
+
-
+
@@ -374,7 +392,9 @@ export default function FeatherAiAgentPage() {
/>
-
+
-
+
-
+
@@ -413,7 +437,9 @@ export default function FeatherAiAgentPage() {
/>
-
+
-
+
-
+
-
+
-
+
-
+
@@ -495,7 +531,9 @@ export default function FeatherAiAgentPage() {
/>
-
+
-
+
{
- Execution Details
+ {t('admin.featherzerotrust.logs.executionDetails')}
- Detailed scan results for execution ID: {executionLog?.execution_id}
+ {t('admin.featherzerotrust.logs.executionDetailsDescription', {
+ id: executionLog?.execution_id ?? '',
+ })}
@@ -331,7 +333,9 @@ const LogsTab = () => {
- Total Servers
+
+ {t('admin.featherzerotrust.logs.totalServers')}
+
{executionLog.total_servers_scanned}
@@ -345,7 +349,9 @@ const LogsTab = () => {
)}
>
- Total Detections
+
+ {t('admin.featherzerotrust.logs.totalDetections')}
+
.
import DashboardShell from '@/components/layout/DashboardShell';
import PermissionGuard from '@/components/auth/PermissionGuard';
+import Permissions from '@/lib/permissions';
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
-
+
{children}
);
diff --git a/frontendv2/src/app/(app)/admin/mail-templates/page.tsx b/frontendv2/src/app/(app)/admin/mail-templates/page.tsx
index fcf8ae1c0..b6c110c70 100644
--- a/frontendv2/src/app/(app)/admin/mail-templates/page.tsx
+++ b/frontendv2/src/app/(app)/admin/mail-templates/page.tsx
@@ -360,7 +360,7 @@ export default function MailTemplatesPage() {
@@ -553,7 +553,7 @@ export default function MailTemplatesPage() {
defaultLanguage='html'
value={formData.body}
onChange={(value) => setFormData({ ...formData, body: value || '' })}
- title='Editor'
+ title={t('admin.mail_templates.form.editor')}
/>
{t('admin.mail_templates.form.html_help')}
@@ -570,7 +570,7 @@ export default function MailTemplatesPage() {
@@ -621,7 +621,7 @@ export default function MailTemplatesPage() {
- setPreviewOpen(false)}>Close
+ setPreviewOpen(false)}>{t('common.close')}
diff --git a/frontendv2/src/app/(app)/admin/nodes/[id]/components/ModulesTab.tsx b/frontendv2/src/app/(app)/admin/nodes/[id]/components/ModulesTab.tsx
index 4991515de..aab5063a9 100644
--- a/frontendv2/src/app/(app)/admin/nodes/[id]/components/ModulesTab.tsx
+++ b/frontendv2/src/app/(app)/admin/nodes/[id]/components/ModulesTab.tsx
@@ -99,11 +99,11 @@ export function ModulesTab({ node }: ModulesTabProps) {
if (data.success) {
setConfigData(JSON.stringify(data.data.config || {}, null, 4));
} else {
- toast.error(data.message || 'Failed to fetch module configuration');
+ toast.error(data.message || t('admin.node.view.modules.config_fetch_failed'));
setConfigModalOpen(false);
}
} catch (err: unknown) {
- let msg = 'Failed to fetch module configuration';
+ let msg = t('admin.node.view.modules.config_fetch_failed');
if (axios.isAxiosError(err)) {
msg = err.response?.data?.message || err.message;
}
@@ -122,7 +122,7 @@ export function ModulesTab({ node }: ModulesTabProps) {
try {
parsedConfig = JSON.parse(configData);
} catch {
- toast.error('Invalid JSON configuration');
+ toast.error(t('admin.node.view.modules.invalid_json_config'));
setSavingConfig(false);
return;
}
diff --git a/frontendv2/src/app/(app)/admin/nodes/[id]/components/WingsConfigTab.tsx b/frontendv2/src/app/(app)/admin/nodes/[id]/components/WingsConfigTab.tsx
index 81a20d1d1..ce1730aa8 100644
--- a/frontendv2/src/app/(app)/admin/nodes/[id]/components/WingsConfigTab.tsx
+++ b/frontendv2/src/app/(app)/admin/nodes/[id]/components/WingsConfigTab.tsx
@@ -136,10 +136,12 @@ export function WingsConfigTab({ node }: WingsConfigTabProps) {
{error ? (
-
Failed to Load Configuration
+
+ {t('admin.node.view.config.load_failed_title')}
+
{error}
- Try Again
+ {t('common.retry')}
) : (
diff --git a/frontendv2/src/app/(app)/admin/nodes/[id]/edit/AllocationsTab.tsx b/frontendv2/src/app/(app)/admin/nodes/[id]/edit/AllocationsTab.tsx
index 6a2225df4..f714e0efc 100644
--- a/frontendv2/src/app/(app)/admin/nodes/[id]/edit/AllocationsTab.tsx
+++ b/frontendv2/src/app/(app)/admin/nodes/[id]/edit/AllocationsTab.tsx
@@ -16,6 +16,7 @@ See the LICENSE file or
.
'use client';
import { useState, useEffect, useCallback } from 'react';
+import { useRouter } from 'next/navigation';
import axios from 'axios';
import { useTranslation } from '@/contexts/TranslationContext';
import { PageCard } from '@/components/featherui/PageCard';
@@ -72,6 +73,7 @@ interface AllocationsTabProps {
export function AllocationsTab({ nodeId, nodeName }: AllocationsTabProps) {
const { t } = useTranslation();
+ const router = useRouter();
const [loading, setLoading] = useState(true);
const [allocations, setAllocations] = useState
([]);
const [searchQuery, setSearchQuery] = useState('');
@@ -100,9 +102,21 @@ export function AllocationsTab({ nodeId, nodeName }: AllocationsTabProps) {
const [bulkDeleteConfirm, setBulkDeleteConfirm] = useState(false);
const [deleteUnusedConfirm, setDeleteUnusedConfirm] = useState(false);
const [deleteUnusedIpFilter, setDeleteUnusedIpFilter] = useState('');
+ const [bulkAddressOpen, setBulkAddressOpen] = useState(false);
+ const [bulkAddressForm, setBulkAddressForm] = useState({
+ from_ip: '',
+ to_ip: '',
+ ip_alias: '',
+ update_alias: false,
+ });
const [submitting, setSubmitting] = useState(false);
const availableIPs = ['0.0.0.0', ...nodeIPs.filter((ip) => ip !== '0.0.0.0')];
+ const allocationIpOptions = Array.from(
+ new Set([...allocations.map((allocation) => allocation.ip), ...availableIPs]),
+ )
+ .filter(Boolean)
+ .sort();
const fetchAllocations = useCallback(async () => {
setLoading(true);
@@ -229,6 +243,51 @@ export function AllocationsTab({ nodeId, nodeName }: AllocationsTabProps) {
}
};
+ const handleBulkAddressUpdate = async () => {
+ const fromIp = bulkAddressForm.from_ip.trim();
+ const toIp = bulkAddressForm.to_ip.trim();
+
+ if (!fromIp || (!toIp && !bulkAddressForm.update_alias)) {
+ toast.error(t('admin.node.allocations.messages.bulk_address_missing'));
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ const payload: {
+ node_id: string | number;
+ from_ip: string;
+ to_ip?: string;
+ ip_alias?: string | null;
+ } = {
+ node_id: nodeId,
+ from_ip: fromIp,
+ };
+
+ if (toIp) {
+ payload.to_ip = toIp;
+ }
+
+ if (bulkAddressForm.update_alias) {
+ payload.ip_alias = bulkAddressForm.ip_alias.trim() || null;
+ }
+
+ await axios.patch('/api/admin/allocations/bulk-address', payload);
+ toast.success(t('admin.node.allocations.messages.bulk_address_success'));
+ setBulkAddressOpen(false);
+ setBulkAddressForm({ from_ip: '', to_ip: '', ip_alias: '', update_alias: false });
+ fetchAllocations();
+ } catch (error: unknown) {
+ console.error('Error updating allocation addresses:', error);
+ const errorMessage = axios.isAxiosError(error)
+ ? error.response?.data?.message
+ : t('admin.node.allocations.messages.bulk_address_failed');
+ toast.error(errorMessage || t('admin.node.allocations.messages.bulk_address_failed'));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
const toggleSelection = (id: number) => {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]));
};
@@ -257,6 +316,10 @@ export function AllocationsTab({ nodeId, nodeName }: AllocationsTabProps) {
{t('admin.node.allocations.delete_unused')}
+ setBulkAddressOpen(true)}>
+
+ {t('admin.node.allocations.change_address')}
+
setCreatingAllocation(true)}>
{t('admin.node.allocations.create_allocation')}
@@ -388,9 +451,15 @@ export function AllocationsTab({ nodeId, nodeName }: AllocationsTabProps) {
{allocation.server_id ? (
-
+
+ router.push(`/admin/servers/${allocation.server_id}/edit`)
+ }
+ >
{allocation.server_name || allocation.server_id}
-
+
) : (
-
)}
@@ -706,6 +775,85 @@ export function AllocationsTab({ nodeId, nodeName }: AllocationsTabProps) {
+
+
+ {t('admin.node.allocations.bulk_address.title')}
+ {t('admin.node.allocations.bulk_address.description')}
+
+
+
+
+ setBulkAddressForm((prev) => ({ ...prev, from_ip: e.target.value }))}
+ />
+
+
+
+
+
+ setBulkAddressForm((prev) => ({ ...prev, to_ip: e.target.value }))}
+ />
+
+ {t('admin.node.allocations.bulk_address.to_ip_help')}
+
+
+
+
+
+ setBulkAddressForm((prev) => ({ ...prev, update_alias: e.target.checked }))
+ }
+ />
+
+
+ setBulkAddressForm((prev) => ({ ...prev, ip_alias: e.target.value }))}
+ />
+
+ {t('admin.node.allocations.bulk_address.alias_help')}
+
+
+
+
+
+ setBulkAddressOpen(false)}>
+ {t('common.cancel')}
+
+
+ {t('admin.node.allocations.bulk_address.submit')}
+
+
+
+
diff --git a/frontendv2/src/app/(app)/admin/nodes/[id]/edit/WingsTab.tsx b/frontendv2/src/app/(app)/admin/nodes/[id]/edit/WingsTab.tsx
index 5eda3da6c..67e045afc 100644
--- a/frontendv2/src/app/(app)/admin/nodes/[id]/edit/WingsTab.tsx
+++ b/frontendv2/src/app/(app)/admin/nodes/[id]/edit/WingsTab.tsx
@@ -38,6 +38,39 @@ interface SetupCommandData {
config_path_hint: string;
}
+interface CommandBlockProps {
+ command: string;
+ copyLabel: string;
+ onCopy: () => void;
+ preClassName?: string;
+}
+
+function CommandBlock({ command, copyLabel, onCopy, preClassName }: CommandBlockProps) {
+ return (
+
+
+
+
+ {copyLabel}
+
+
+
+ {command}
+
+
+ );
+}
+
export function WingsTab({ nodeId, wingsConfigYaml, handleResetKey, resetting }: WingsTabProps) {
const { t } = useTranslation();
const [setupData, setSetupData] = useState(null);
@@ -79,21 +112,11 @@ export function WingsTab({ nodeId, wingsConfigYaml, handleResetKey, resetting }:
{t('admin.node.wings.setup_step_1')}
-
-
- {setupData.install_command}
-
- copyToClipboard(setupData.install_command, t)}
- >
-
- {t('admin.node.wings.copy_setup_command')}
-
-
+ copyToClipboard(setupData.install_command, t)}
+ />
{/* Step 2: Fetch config and restart */}
{setupData.setup_command && (
@@ -101,21 +124,11 @@ export function WingsTab({ nodeId, wingsConfigYaml, handleResetKey, resetting }:
{t('admin.node.wings.setup_step_2')}
-
-
- {setupData.setup_command}
-
- copyToClipboard(setupData.setup_command, t)}
- >
-
- {t('admin.node.wings.copy_setup_command')}
-
-
+ copyToClipboard(setupData.setup_command, t)}
+ />
)}
{t('admin.node.wings.setup_command_then')}
@@ -131,21 +144,12 @@ export function WingsTab({ nodeId, wingsConfigYaml, handleResetKey, resetting }:
{t('admin.node.wings.config_help')}
-
-
- {wingsConfigYaml}
-
- copyToClipboard(wingsConfigYaml, t)}
- >
-
- {t('admin.node.wings.copy_config')}
-
-
+ copyToClipboard(wingsConfigYaml, t)}
+ preClassName='scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent overflow-x-auto p-6 font-mono text-xs whitespace-pre text-zinc-300'
+ />
diff --git a/frontendv2/src/app/(app)/admin/nodes/[id]/edit/page.tsx b/frontendv2/src/app/(app)/admin/nodes/[id]/edit/page.tsx
index 208cb2985..63f8075f3 100644
--- a/frontendv2/src/app/(app)/admin/nodes/[id]/edit/page.tsx
+++ b/frontendv2/src/app/(app)/admin/nodes/[id]/edit/page.tsx
@@ -41,7 +41,9 @@ import {
MapPin,
ChevronLeft,
ChevronRight,
+ ArrowLeftRight,
} from 'lucide-react';
+import { MassTransferServersDialog } from '@/components/admin/MassTransferServersDialog';
import { DetailsTab } from './DetailsTab';
import { ConfigurationTab } from './ConfigurationTab';
@@ -101,6 +103,7 @@ export default function EditNodePage() {
const [resetting, setResetting] = useState(false);
const [activeTab, setActiveTab] = useState(tabFromUrl === 'wings' ? 'wings' : 'details');
const [locationModalOpen, setLocationModalOpen] = useState(false);
+ const [massTransferOpen, setMassTransferOpen] = useState(false);
const { fetchWidgets, getWidgets } = usePluginWidgets('admin-nodes-edit');
@@ -501,11 +504,17 @@ remote: '${typeof window !== 'undefined' ? window.location.origin : 'https://pan
description={t('admin.node.form.edit_description')}
icon={Server}
actions={
-
+
router.back()}>
{t('common.back')}
+ {nodeData ? (
+ setMassTransferOpen(true)}>
+
+ {t('admin.node.mass_transfer.button')}
+
+ ) : null}
handleSubmit()} loading={saving}>
{t('admin.node.form.submit_save')}
@@ -825,6 +834,15 @@ remote: '${typeof window !== 'undefined' ? window.location.origin : 'https://pan
widgets={getWidgets('admin-nodes-edit', 'bottom-of-page')}
context={{ id: nodeId as string }}
/>
+
+ {nodeData ? (
+
+ ) : null}
);
}
diff --git a/frontendv2/src/app/(app)/admin/nodes/page.tsx b/frontendv2/src/app/(app)/admin/nodes/page.tsx
index 351ebb147..a749b9dc3 100644
--- a/frontendv2/src/app/(app)/admin/nodes/page.tsx
+++ b/frontendv2/src/app/(app)/admin/nodes/page.tsx
@@ -42,7 +42,9 @@ import {
MapPin,
Shield,
Network,
+ ArrowLeftRight,
} from 'lucide-react';
+import { MassTransferServersDialog } from '@/components/admin/MassTransferServersDialog';
interface Node {
id: number;
@@ -97,6 +99,7 @@ export default function NodesPage() {
const [refreshKey, setRefreshKey] = useState(0);
const [confirmDeleteId, setConfirmDeleteId] = useState (null);
const [deleting, setDeleting] = useState(false);
+ const [massTransferNode, setMassTransferNode] = useState(null);
const [pagination, setPagination] = useState({
page: 1,
@@ -372,6 +375,14 @@ export default function NodesPage() {
>
+ setMassTransferNode(node)}
+ title={t('admin.node.mass_transfer.button')}
+ >
+
+
+
+ {massTransferNode ? (
+ {
+ if (!open) setMassTransferNode(null);
+ }}
+ onCompleted={() => setRefreshKey((prev) => prev + 1)}
+ />
+ ) : null}
);
}
diff --git a/frontendv2/src/app/(app)/admin/oidc-providers/page.tsx b/frontendv2/src/app/(app)/admin/oidc-providers/page.tsx
index f06d66e69..ef222e611 100644
--- a/frontendv2/src/app/(app)/admin/oidc-providers/page.tsx
+++ b/frontendv2/src/app/(app)/admin/oidc-providers/page.tsx
@@ -57,7 +57,7 @@ export default function OidcProvidersPage() {
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
- toast.success('Copied to clipboard');
+ toast.success(t('common.copiedToClipboard'));
};
const fetchProviders = useCallback(async () => {
diff --git a/frontendv2/src/app/(app)/admin/plugins/page.tsx b/frontendv2/src/app/(app)/admin/plugins/page.tsx
index faa538086..e44b38227 100644
--- a/frontendv2/src/app/(app)/admin/plugins/page.tsx
+++ b/frontendv2/src/app/(app)/admin/plugins/page.tsx
@@ -149,6 +149,7 @@ export default function PluginsPage() {
const [updateRequirements, setUpdateRequirements] = useState (null);
const [installingUpdateId, setInstallingUpdateId] = useState(null);
const [pluginsWithUpdates, setPluginsWithUpdates] = useState([]);
+ const [bulkUpdatingPlugins, setBulkUpdatingPlugins] = useState(false);
const { fetchWidgets, getWidgets } = usePluginWidgets('admin-plugins');
@@ -557,6 +558,57 @@ export default function PluginsPage() {
}
};
+ const installAllUpdates = async () => {
+ if (bulkUpdatingPlugins || pluginsWithUpdates.length === 0) return;
+
+ setBulkUpdatingPlugins(true);
+ const queuedIdentifiers = pluginsWithUpdates.map((plugin) => plugin.identifier);
+ const failures: string[] = [];
+ let updatedCount = 0;
+
+ try {
+ for (const plugin of pluginsWithUpdates) {
+ setInstallingUpdateId(plugin.identifier);
+ try {
+ await axios.post('/api/admin/plugins/online/install', {
+ identifier: plugin.identifier,
+ queued_identifiers: queuedIdentifiers,
+ });
+ updatedCount += 1;
+ } catch (error) {
+ const message = axios.isAxiosError(error)
+ ? error.response?.data?.message || t('admin.plugins.messages.update_failed')
+ : t('admin.plugins.messages.update_failed');
+ failures.push(`${plugin.name || plugin.identifier}: ${message}`);
+ }
+ }
+
+ if (failures.length === 0) {
+ toast.success(t('admin.plugins.messages.bulk_update_success', { count: String(updatedCount) }));
+ } else if (updatedCount > 0) {
+ toast.error(
+ t('admin.plugins.messages.bulk_update_partial', {
+ success: String(updatedCount),
+ failed: String(failures.length),
+ }),
+ );
+ console.error('Some plugin updates failed:', failures);
+ } else {
+ toast.error(t('admin.plugins.messages.bulk_update_failed'));
+ console.error('Plugin updates failed:', failures);
+ }
+
+ if (updatedCount > 0) {
+ fetchPlugins();
+ checkAllUpdates();
+ setTimeout(() => window.location.reload(), 1500);
+ }
+ } finally {
+ setInstallingUpdateId(null);
+ setBulkUpdatingPlugins(false);
+ }
+ };
+
const configFields = useMemo(() => pluginConfig?.configSchema || [], [pluginConfig]);
const hasConfigSchema = configFields.length > 0;
@@ -577,6 +629,16 @@ export default function PluginsPage() {
{t('admin.plugins.actions.check_updates')}
+ {pluginsWithUpdates.length > 0 && (
+
+
+ {t('admin.plugins.actions.update_all', { count: String(pluginsWithUpdates.length) })}
+
+ )}
+
+
+ {t('admin.plugins.actions.update_all', { count: String(pluginsWithUpdates.length) })}
+
diff --git a/frontendv2/src/app/(app)/admin/pterodactyl-importer/page.tsx b/frontendv2/src/app/(app)/admin/pterodactyl-importer/page.tsx
index a07a50430..87f4ee147 100644
--- a/frontendv2/src/app/(app)/admin/pterodactyl-importer/page.tsx
+++ b/frontendv2/src/app/(app)/admin/pterodactyl-importer/page.tsx
@@ -249,14 +249,14 @@ export default function PterodactylImporterPage() {
if (buffer === cheatCode) {
setBypassPrerequisites(true);
- toast.success('Cheat code activated: Prerequisites bypassed!');
+ toast.success(t('admin.pterodactyl_importer.toasts.cheat_code_activated'));
buffer = '';
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
- }, []);
+ }, [t]);
return (
diff --git a/frontendv2/src/app/(app)/admin/servers/[id]/edit/ActionsTab.tsx b/frontendv2/src/app/(app)/admin/servers/[id]/edit/ActionsTab.tsx
index 5e5bebab2..fb70aaa15 100644
--- a/frontendv2/src/app/(app)/admin/servers/[id]/edit/ActionsTab.tsx
+++ b/frontendv2/src/app/(app)/admin/servers/[id]/edit/ActionsTab.tsx
@@ -24,6 +24,7 @@ import { Button } from '@/components/featherui/Button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/featherui/Input';
import { HeadlessModal } from '@/components/ui/headless-modal';
+import { Checkbox } from '@/components/ui/checkbox';
import {
AlertDialog,
AlertDialogAction,
@@ -34,6 +35,12 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
+import {
+ ModerationReasonFields,
+ ModerationReasonValue,
+ isModerationReasonValid,
+} from '@/components/admin/ModerationReasonFields';
+import { ModerationStatusCard, ModerationStaffActor } from '@/components/admin/ModerationStatusCard';
import { toast } from 'sonner';
import { Pause, Play, Trash2, AlertTriangle, ArrowLeftRight, Search, ChevronRight, Loader2 } from 'lucide-react';
import { ApiNode, ApiAllocation } from '@/types/adminServerTypes';
@@ -42,11 +49,28 @@ interface ActionsTabProps {
serverId: string;
serverName: string;
isSuspended: boolean;
+ suspensionReason?: string | null;
+ suspendedAt?: string | null;
+ suspendedBy?: ModerationStaffActor | null;
currentNodeId?: number | null;
onRefresh: () => void;
}
-export function ActionsTab({ serverId, serverName, isSuspended, currentNodeId, onRefresh }: ActionsTabProps) {
+const emptyReason: ModerationReasonValue = {
+ reason_category: '',
+ reason_details: '',
+};
+
+export function ActionsTab({
+ serverId,
+ serverName,
+ isSuspended,
+ suspensionReason,
+ suspendedAt,
+ suspendedBy,
+ currentNodeId,
+ onRefresh,
+}: ActionsTabProps) {
const { t } = useTranslation();
const router = useRouter();
const [suspending, setSuspending] = useState(false);
@@ -58,6 +82,7 @@ export function ActionsTab({ serverId, serverName, isSuspended, currentNodeId, o
const [allocationModalOpen, setAllocationModalOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState (null);
const [selectedAllocation, setSelectedAllocation] = useState(null);
+ const [autoAllocate, setAutoAllocate] = useState(true);
const [nodes, setNodes] = useState([]);
const [allocations, setAllocations] = useState([]);
const [nodeSearch, setNodeSearch] = useState('');
@@ -66,16 +91,29 @@ export function ActionsTab({ serverId, serverName, isSuspended, currentNodeId, o
const [loadingAllocations, setLoadingAllocations] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [suspendDialogOpen, setSuspendDialogOpen] = useState(false);
+ const [suspendReason, setSuspendReason] = useState(emptyReason);
const handleSuspend = async () => {
+ if (!isModerationReasonValid(suspendReason)) {
+ toast.error(t('admin.moderation.reason_required'));
+ return;
+ }
+
setSuspending(true);
try {
- await axios.post(`/api/admin/servers/${serverId}/suspend`);
+ await axios.post(`/api/admin/servers/${serverId}/suspend`, suspendReason);
toast.success(t('admin.servers.edit.actions.suspend_success'));
+ setSuspendDialogOpen(false);
+ setSuspendReason(emptyReason);
onRefresh();
- } catch (error) {
+ } catch (error: unknown) {
console.error('Error suspending server:', error);
- toast.error(t('admin.servers.edit.actions.suspend_failed'));
+ const message =
+ axios.isAxiosError(error) && error.response?.data?.message
+ ? String(error.response.data.message)
+ : t('admin.servers.edit.actions.suspend_failed');
+ toast.error(message);
} finally {
setSuspending(false);
}
@@ -154,13 +192,21 @@ export function ActionsTab({ serverId, serverName, isSuspended, currentNodeId, o
};
const handleTransfer = async () => {
- if (!selectedNode || !selectedAllocation) return;
+ if (!selectedNode || (!autoAllocate && !selectedAllocation)) return;
setTransferring(true);
try {
- await axios.post(`/api/admin/servers/${serverId}/transfer`, {
+ const payload: {
+ destination_node_id: number;
+ destination_allocation_id?: number;
+ auto_allocate?: boolean;
+ } = {
destination_node_id: selectedNode.id,
- destination_allocation_id: selectedAllocation.id,
- });
+ auto_allocate: autoAllocate,
+ };
+ if (!autoAllocate && selectedAllocation) {
+ payload.destination_allocation_id = selectedAllocation.id;
+ }
+ await axios.post(`/api/admin/servers/${serverId}/transfer`, payload);
toast.success(t('admin.servers.messages.transfer_initiated'));
setTransferDialogOpen(false);
onRefresh();
@@ -178,26 +224,44 @@ export function ActionsTab({ serverId, serverName, isSuspended, currentNodeId, o
title={t('admin.servers.edit.actions.suspension_title')}
description={t('admin.servers.edit.actions.suspension_description')}
>
-
-
- {t('admin.servers.edit.actions.status')}:
-
- {isSuspended
- ? t('admin.servers.edit.actions.suspended')
- : t('admin.servers.edit.actions.active')}
-
+
+
+
+ {t('admin.servers.edit.actions.status')}:
+
+ {isSuspended
+ ? t('admin.servers.edit.actions.suspended')
+ : t('admin.servers.edit.actions.active')}
+
+
+ {isSuspended ? (
+
+
+ {t('admin.servers.edit.actions.unsuspend')}
+
+ ) : (
+ {
+ setSuspendReason(emptyReason);
+ setSuspendDialogOpen(true);
+ }}
+ loading={suspending}
+ >
+
+ {t('admin.servers.edit.actions.suspend')}
+
+ )}
- {isSuspended ? (
-
-
- {t('admin.servers.edit.actions.unsuspend')}
-
- ) : (
-
-
- {t('admin.servers.edit.actions.suspend')}
-
- )}
+
+
@@ -236,6 +300,7 @@ export function ActionsTab({ serverId, serverName, isSuspended, currentNodeId, o
onClick={() => {
setSelectedNode(null);
setSelectedAllocation(null);
+ setAutoAllocate(true);
setTransferDialogOpen(true);
}}
>
@@ -293,6 +358,34 @@ export function ActionsTab({ serverId, serverName, isSuspended, currentNodeId, o
+
+
+
+
+
+ {t('admin.servers.edit.actions.suspend_confirm_title')}
+
+
+ {t('admin.servers.edit.actions.suspend_confirm_description', { name: serverName })}
+
+
+
+
+ {t('common.cancel')}
+ {
+ e.preventDefault();
+ void handleSuspend();
+ }}
+ className='bg-red-600 hover:bg-red-700'
+ disabled={suspending || !isModerationReasonValid(suspendReason)}
+ >
+ {suspending ? t('common.loading') : t('admin.servers.edit.actions.suspend')}
+
+
+
+
+
@@ -315,29 +408,47 @@ export function ActionsTab({ serverId, serverName, isSuspended, currentNodeId, o
- {
- if (!selectedNode) return;
- fetchAllocations(selectedNode.id);
- setAllocationModalOpen(true);
- }}
- >
-
- {selectedAllocation
- ? `${selectedAllocation.ip}:${selectedAllocation.port}`
- : t('admin.servers.transfer.select_allocation')}
-
-
-
+
+ {!autoAllocate ? (
+ {
+ if (!selectedNode) return;
+ fetchAllocations(selectedNode.id);
+ setAllocationModalOpen(true);
+ }}
+ >
+
+ {selectedAllocation
+ ? `${selectedAllocation.ip}:${selectedAllocation.port}`
+ : t('admin.servers.transfer.select_allocation')}
+
+
+
+ ) : null}
{t('common.cancel')}
{transferring ? : null}
{t('admin.servers.transfer.submit')}
diff --git a/frontendv2/src/app/(app)/admin/servers/[id]/edit/StartupTab.tsx b/frontendv2/src/app/(app)/admin/servers/[id]/edit/StartupTab.tsx
index 6bcaaf0a2..e07b1a96b 100644
--- a/frontendv2/src/app/(app)/admin/servers/[id]/edit/StartupTab.tsx
+++ b/frontendv2/src/app/(app)/admin/servers/[id]/edit/StartupTab.tsx
@@ -15,13 +15,47 @@ See the LICENSE file or .
'use client';
+import type { Dispatch, SetStateAction } from 'react';
import { useTranslation } from '@/contexts/TranslationContext';
import { PageCard } from '@/components/featherui/PageCard';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
-import { TabProps } from './types';
+import { Button } from '@/components/featherui/Button';
+import { Plus, Trash2, Lock } from 'lucide-react';
+import { CustomVariable, TabProps } from './types';
-export function StartupTab({ form, setForm, errors }: TabProps) {
+interface StartupTabProps extends TabProps {
+ customVariables: CustomVariable[];
+ customVariableForm: {
+ name: string;
+ env_variable: string;
+ variable_value: string;
+ is_encrypted: boolean;
+ };
+ customVariableSaving: boolean;
+ setCustomVariableForm: Dispatch<
+ SetStateAction<{
+ name: string;
+ env_variable: string;
+ variable_value: string;
+ is_encrypted: boolean;
+ }>
+ >;
+ onAddCustomVariable: () => void;
+ onDeleteCustomVariable: (variable: CustomVariable) => void;
+}
+
+export function StartupTab({
+ form,
+ setForm,
+ errors,
+ customVariables,
+ customVariableForm,
+ customVariableSaving,
+ setCustomVariableForm,
+ onAddCustomVariable,
+ onDeleteCustomVariable,
+}: StartupTabProps) {
const { t } = useTranslation();
return (
@@ -51,6 +85,109 @@ export function StartupTab({ form, setForm, errors }: TabProps) {
{'{{SERVER_PORT}}'}
+
+
+
+ Custom environment variables
+
+ These are synced to Wings without a server transfer. Encrypted values are hidden after
+ creation.
+
+
+
+ {customVariables.length > 0 && (
+
+ {customVariables.map((variable) => (
+
+
+
+ {variable.name}
+ {Number(variable.is_encrypted) === 1 && (
+
+ )}
+
+
+ {variable.env_variable}={variable.variable_value}
+
+
+ onDeleteCustomVariable(variable)}
+ disabled={customVariableSaving}
+ className='shrink-0'
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
);
diff --git a/frontendv2/src/app/(app)/admin/servers/[id]/edit/page.tsx b/frontendv2/src/app/(app)/admin/servers/[id]/edit/page.tsx
index 49974ac61..dc8a1cd96 100644
--- a/frontendv2/src/app/(app)/admin/servers/[id]/edit/page.tsx
+++ b/frontendv2/src/app/(app)/admin/servers/[id]/edit/page.tsx
@@ -15,10 +15,11 @@ See the LICENSE file or .
'use client';
-import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
+import { useState, useCallback, useEffect, useRef } from 'react';
import axios from 'axios';
import { useParams, usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useTranslation } from '@/contexts/TranslationContext';
+import { useDateFormatOptions } from '@/contexts/PreferencesContext';
import { Button } from '@/components/featherui/Button';
import { Input } from '@/components/ui/input';
import { PageHeader } from '@/components/featherui/PageHeader';
@@ -43,8 +44,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Sheet, SheetHeader, SheetTitle, SheetDescription, SheetContent } from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarImage } from '@/components/ui/avatar';
-import { HeadlessModal } from '@/components/ui/headless-modal';
import { toast } from 'sonner';
+import { formatDateTimeInTz, formatRelativeTime } from '@/lib/dateUtils';
import { DetailsTab } from './DetailsTab';
import { ResourcesTab } from './ResourcesTab';
@@ -55,6 +56,7 @@ import { AllocationsTab } from './AllocationsTab';
import { MountsTab } from './MountsTab';
import type { AssignableMountRow } from './MountsTab';
import { ActionsTab } from './ActionsTab';
+import { AllocationPickerSheet } from '@/components/admin/AllocationPickerSheet';
import { usePluginWidgets } from '@/hooks/usePluginWidgets';
import { WidgetRenderer } from '@/components/server/WidgetRenderer';
@@ -68,6 +70,7 @@ import {
Realm,
Spell,
SpellVariable,
+ CustomVariable,
} from './types';
const initialFormData: ServerFormData = {
@@ -120,6 +123,7 @@ interface ServerVariableResponse {
export default function EditServerPage() {
const { t } = useTranslation();
+ const dateOpts = useDateFormatOptions();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -134,12 +138,23 @@ export default function EditServerPage() {
const [form, setForm] = useState(initialFormData);
const [selectedEntities, setSelectedEntities] = useState(initialSelectedEntities);
const [isSuspended, setIsSuspended] = useState(false);
+ const [suspensionReason, setSuspensionReason] = useState(null);
+ const [suspendedAt, setSuspendedAt] = useState(null);
+ const [suspendedBy, setSuspendedBy] = useState<{ uuid?: string | null; username?: string | null } | null>(null);
const [location, setLocation] = useState(null);
const [node, setNode] = useState(null);
const [spellDetails, setSpellDetails] = useState(null);
const [spellVariables, setSpellVariables] = useState([]);
+ const [customVariables, setCustomVariables] = useState([]);
+ const [customVariableSaving, setCustomVariableSaving] = useState(false);
+ const [customVariableForm, setCustomVariableForm] = useState({
+ name: '',
+ env_variable: '',
+ variable_value: '',
+ is_encrypted: false,
+ });
const [dockerImages, setDockerImages] = useState([]);
const [ownerModalOpen, setOwnerModalOpen] = useState(false);
@@ -187,6 +202,15 @@ export default function EditServerPage() {
const [debouncedSpellSearch, setDebouncedSpellSearch] = useState('');
const [allocationSearch, setAllocationSearch] = useState('');
+ const [debouncedAllocationSearch, setDebouncedAllocationSearch] = useState('');
+ const [allocationPagination, setAllocationPagination] = useState({
+ current_page: 1,
+ per_page: 20,
+ total_records: 0,
+ total_pages: 0,
+ has_next: false,
+ has_prev: false,
+ });
const tabStorageKey = `featherpanel_admin_server_edit_tab_${serverId}`;
const isAllowedTab = useCallback(
@@ -241,17 +265,13 @@ export default function EditServerPage() {
fetchWidgets();
}, [fetchWidgets]);
- const filteredAllocations = useMemo(() => {
- if (!allocationSearch) return allocations;
- const lowerSearch = allocationSearch.toLowerCase();
- return allocations.filter((a) => {
- return (
- a.ip.toLowerCase().includes(lowerSearch) ||
- String(a.port).includes(lowerSearch) ||
- (a.ip_alias && a.ip_alias.toLowerCase().includes(lowerSearch))
- );
- });
- }, [allocations, allocationSearch]);
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedAllocationSearch(allocationSearch);
+ setAllocationPagination((prev) => ({ ...prev, current_page: 1 }));
+ }, 500);
+ return () => clearTimeout(timer);
+ }, [allocationSearch]);
useEffect(() => {
const timer = setTimeout(() => {
@@ -322,6 +342,7 @@ export default function EditServerPage() {
}
const variablesList = (server.variables || []) as ServerVariableResponse[];
+ setCustomVariables((server.custom_variables || []) as CustomVariable[]);
const mappedVariables: SpellVariable[] = variablesList.map((v) => ({
id: v.variable_id,
name: v.name,
@@ -410,6 +431,9 @@ export default function EditServerPage() {
});
setIsSuspended(Boolean(server.suspended));
+ setSuspensionReason(server.suspension_reason ?? null);
+ setSuspendedAt(server.suspended_at ?? null);
+ setSuspendedBy(server.suspended_by ?? null);
setNode(serverNode || null);
setLocation(serverLocation);
@@ -482,6 +506,79 @@ export default function EditServerPage() {
fetchServerData();
}, [fetchServerData]);
+ const handleAddCustomVariable = useCallback(async () => {
+ const name = customVariableForm.name.trim();
+ const envVariable = customVariableForm.env_variable.trim().toUpperCase();
+
+ if (!name || !envVariable) {
+ toast.error('Name and environment variable are required');
+ return;
+ }
+
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(envVariable)) {
+ toast.error(
+ 'Env variable must use uppercase letters, numbers, and underscores, and cannot start with a number',
+ );
+ return;
+ }
+
+ setCustomVariableSaving(true);
+ try {
+ const { data } = await axios.post<{ success: boolean; message?: string }>(
+ `/api/admin/servers/${serverId}/custom-variables`,
+ {
+ name,
+ env_variable: envVariable,
+ variable_value: customVariableForm.variable_value,
+ is_encrypted: customVariableForm.is_encrypted,
+ },
+ );
+
+ if (data.success) {
+ toast.success('Custom variable added');
+ setCustomVariableForm({ name: '', env_variable: '', variable_value: '', is_encrypted: false });
+ await fetchServerData();
+ } else {
+ toast.error(data.message || 'Failed to add custom variable');
+ }
+ } catch (error) {
+ toast.error(
+ axios.isAxiosError(error)
+ ? error.response?.data?.message || 'Failed to add custom variable'
+ : 'Failed to add custom variable',
+ );
+ } finally {
+ setCustomVariableSaving(false);
+ }
+ }, [customVariableForm, fetchServerData, serverId]);
+
+ const handleDeleteCustomVariable = useCallback(
+ async (variable: CustomVariable) => {
+ setCustomVariableSaving(true);
+ try {
+ const { data } = await axios.delete<{ success: boolean; message?: string }>(
+ `/api/admin/servers/${serverId}/custom-variables/${variable.id}`,
+ );
+
+ if (data.success) {
+ toast.success('Custom variable deleted');
+ await fetchServerData();
+ } else {
+ toast.error(data.message || 'Failed to delete custom variable');
+ }
+ } catch (error) {
+ toast.error(
+ axios.isAxiosError(error)
+ ? error.response?.data?.message || 'Failed to delete custom variable'
+ : 'Failed to delete custom variable',
+ );
+ } finally {
+ setCustomVariableSaving(false);
+ }
+ },
+ [fetchServerData, serverId],
+ );
+
useEffect(() => {
if (!form.spell_id) {
setSpellDetails(null);
@@ -638,49 +735,97 @@ export default function EditServerPage() {
}
}, [spellModalOpen, form.realms_id, spellPagination.current_page, debouncedSpellSearch, fetchSpells]);
- const fetchAllocations = async (mode: 'form' | 'primary' | 'assign' = 'form') => {
+ const matchesAllocationSearch = useCallback((allocation: Allocation, search: string) => {
+ if (!search) return true;
+ const lowerSearch = search.toLowerCase();
+ return (
+ allocation.ip.toLowerCase().includes(lowerSearch) ||
+ String(allocation.port).includes(lowerSearch) ||
+ (allocation.ip_alias && allocation.ip_alias.toLowerCase().includes(lowerSearch))
+ );
+ }, []);
+
+ const fetchAllocations = useCallback(async () => {
if (!node?.id) return;
try {
- if (mode === 'primary') {
+ const listParams = {
+ node_id: node.id,
+ not_used: true,
+ search: debouncedAllocationSearch || undefined,
+ page: allocationPagination.current_page,
+ limit: allocationPagination.per_page,
+ };
+
+ if (allocationModalMode === 'primary') {
const [availableRes, assignedRes] = await Promise.all([
- axios.get('/api/admin/allocations', { params: { not_used: true } }),
+ axios.get('/api/admin/allocations', { params: listParams }),
axios.get(`/api/admin/servers/${serverId}/allocations`),
]);
- const available = (availableRes.data?.data?.allocations || []).filter(
- (a: Allocation) => a.node_id === node.id,
+ const available = (availableRes.data?.data?.allocations || []) as Allocation[];
+ const assigned = ((assignedRes.data?.data?.allocations || []) as Allocation[]).filter(
+ (allocation) =>
+ allocation.node_id === node.id &&
+ matchesAllocationSearch(allocation, debouncedAllocationSearch),
);
- const assigned = assignedRes.data?.data?.allocations || [];
const merged = new Map();
- [...available, ...assigned].forEach((a: Allocation) => {
- merged.set(a.id, a);
+ [...available, ...assigned].forEach((allocation) => {
+ merged.set(allocation.id, allocation);
});
setAllocations(Array.from(merged.values()));
+ if (availableRes.data?.data?.pagination) {
+ setAllocationPagination((prev) => ({
+ ...prev,
+ ...availableRes.data.data.pagination,
+ }));
+ }
return;
}
- const { data } = await axios.get('/api/admin/allocations', { params: { not_used: true } });
- const allAllocations = data.data.allocations || [];
-
- const filtered = allAllocations.filter((a: Allocation) => a.node_id === node.id);
+ const { data } = await axios.get('/api/admin/allocations', { params: listParams });
+ let nextAllocations = (data.data.allocations || []) as Allocation[];
if (form.allocation_id && selectedEntities.allocation) {
- if (!filtered.find((a: Allocation) => a.id === form.allocation_id)) {
- filtered.push(selectedEntities.allocation);
+ if (!nextAllocations.find((allocation) => allocation.id === form.allocation_id)) {
+ nextAllocations = [...nextAllocations, selectedEntities.allocation];
}
}
- setAllocations(filtered);
+ setAllocations(nextAllocations);
+ if (data.data.pagination) {
+ setAllocationPagination((prev) => ({
+ ...prev,
+ ...data.data.pagination,
+ }));
+ }
} catch (error) {
console.error('Error fetching allocations:', error);
}
- };
+ }, [
+ node?.id,
+ allocationModalMode,
+ debouncedAllocationSearch,
+ allocationPagination.current_page,
+ allocationPagination.per_page,
+ serverId,
+ form.allocation_id,
+ selectedEntities.allocation,
+ matchesAllocationSearch,
+ ]);
+
+ useEffect(() => {
+ if (allocationModalOpen && node?.id) {
+ fetchAllocations();
+ }
+ }, [allocationModalOpen, node?.id, fetchAllocations]);
- const openAllocationModal = async (mode: 'form' | 'primary' | 'assign') => {
+ const openAllocationModal = (mode: 'form' | 'primary' | 'assign') => {
setAllocationModalMode(mode);
- await fetchAllocations(mode);
+ setAllocationSearch('');
+ setDebouncedAllocationSearch('');
+ setAllocationPagination((prev) => ({ ...prev, current_page: 1 }));
setAllocationModalOpen(true);
};
@@ -971,7 +1116,17 @@ export default function EditServerPage() {
-
+
@@ -997,6 +1152,9 @@ export default function EditServerPage() {
serverId={serverId}
serverName={form.name}
isSuspended={isSuspended}
+ suspensionReason={suspensionReason}
+ suspendedAt={suspendedAt}
+ suspendedBy={suspendedBy}
currentNodeId={node?.id}
onRefresh={fetchServerData}
/>
@@ -1105,7 +1263,9 @@ export default function EditServerPage() {
{user.last_seen && (
{t('admin.users.last_seen')}:{' '}
- {new Date(user.last_seen).toLocaleDateString()}
+
+ {formatRelativeTime(user.last_seen, dateOpts)}
+
)}
@@ -1451,80 +1611,22 @@ export default function EditServerPage() {
- setAllocationModalOpen(false)}
- title={t('admin.servers.form.select_allocation')}
- items={filteredAllocations}
- onSelect={handleSelectAllocation}
- search={allocationSearch}
- onSearchChange={setAllocationSearch}
- renderItem={(item: Allocation) => (
-
-
- {item.ip}:{item.port}
-
- {item.ip_alias && {item.ip_alias} }
-
- )}
- />
+ {node?.id != null && (
+
+ )}
);
}
-
-interface SelectionModalProps {
- isOpen: boolean;
- onClose: () => void;
- title: string;
- items: T[];
- onSelect: (item: T) => void;
- search: string;
- onSearchChange: (val: string) => void;
- renderItem: (item: T) => React.ReactNode;
-}
-
-function SelectionModal({
- isOpen,
- onClose,
- title,
- items,
- onSelect,
- search,
- onSearchChange,
- renderItem,
-}: SelectionModalProps) {
- const { t } = useTranslation();
- return (
-
-
-
-
- onSearchChange(e.target.value)}
- className='h-10 pl-10'
- />
-
-
-
- {items.length === 0 ? (
- {t('common.no_results')}
- ) : (
- items.map((item) => (
- onSelect(item)}
- >
- {renderItem(item)}
-
- ))
- )}
-
-
-
- );
-}
diff --git a/frontendv2/src/app/(app)/admin/servers/[id]/edit/types.ts b/frontendv2/src/app/(app)/admin/servers/[id]/edit/types.ts
index 496b51895..e6f5ed174 100644
--- a/frontendv2/src/app/(app)/admin/servers/[id]/edit/types.ts
+++ b/frontendv2/src/app/(app)/admin/servers/[id]/edit/types.ts
@@ -75,7 +75,7 @@ export interface Allocation {
id: number;
ip: string;
port: number;
- ip_alias: string | null;
+ ip_alias?: string | null;
server_id: number | null;
node_id: number;
is_primary?: boolean;
@@ -108,6 +108,18 @@ export interface SpellVariable {
field_type: string;
}
+export interface CustomVariable {
+ id: number;
+ server_id: number;
+ user_id: number;
+ name: string;
+ env_variable: string;
+ variable_value: string;
+ is_encrypted: number;
+ created_at?: string;
+ updated_at?: string;
+}
+
export interface ServerFormData {
name: string;
description: string;
diff --git a/frontendv2/src/app/(app)/admin/servers/create/page.tsx b/frontendv2/src/app/(app)/admin/servers/create/page.tsx
index a728968b9..b471d2b12 100644
--- a/frontendv2/src/app/(app)/admin/servers/create/page.tsx
+++ b/frontendv2/src/app/(app)/admin/servers/create/page.tsx
@@ -290,12 +290,12 @@ export default function CreateServerPage() {
}
} catch (error) {
console.error('Error fetching spell details:', error);
- toast.error('Failed to fetch spell details');
+ toast.error(t('admin.servers.form.messages.spell_details_failed'));
}
};
fetchSpellDetails();
- }, [formData.spellId]);
+ }, [formData.spellId, t]);
useEffect(() => {
const timer = setTimeout(() => {
@@ -614,16 +614,16 @@ export default function CreateServerPage() {
const { data } = await axios.put('/api/admin/servers', payload);
if (data.success) {
- toast.success('Server created successfully!');
+ toast.success(t('admin.servers.form.messages.created'));
router.push('/admin/servers');
} else {
- toast.error(data.message || 'Failed to create server');
+ toast.error(data.message || t('admin.servers.form.messages.create_failed'));
}
} catch (error) {
if (isAxiosError(error)) {
- toast.error(error.response?.data?.message || 'Failed to create server');
+ toast.error(error.response?.data?.message || t('admin.servers.form.messages.create_failed'));
} else {
- toast.error('An unexpected error occurred');
+ toast.error(t('account.unexpectedError'));
}
} finally {
setSubmitting(false);
diff --git a/frontendv2/src/app/(app)/admin/servers/create/types.ts b/frontendv2/src/app/(app)/admin/servers/create/types.ts
index 29b1cd246..f13adfa6e 100644
--- a/frontendv2/src/app/(app)/admin/servers/create/types.ts
+++ b/frontendv2/src/app/(app)/admin/servers/create/types.ts
@@ -55,7 +55,7 @@ export interface Allocation {
id: number;
ip: string;
port: number;
- ip_alias?: string;
+ ip_alias?: string | null;
server_id: number | null;
node_id: number;
}
diff --git a/frontendv2/src/app/(app)/admin/servers/page.tsx b/frontendv2/src/app/(app)/admin/servers/page.tsx
index 2433a397c..1960ae582 100644
--- a/frontendv2/src/app/(app)/admin/servers/page.tsx
+++ b/frontendv2/src/app/(app)/admin/servers/page.tsx
@@ -15,10 +15,11 @@ See the LICENSE file or .
'use client';
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, type ReactNode, type ElementType } from 'react';
import { useRouter } from 'next/navigation';
import axios, { isAxiosError } from 'axios';
import { useTranslation } from '@/contexts/TranslationContext';
+import { useDateFormatOptions } from '@/contexts/PreferencesContext';
import { PageHeader } from '@/components/featherui/PageHeader';
import { Button } from '@/components/featherui/Button';
import { Input } from '@/components/featherui/Input';
@@ -52,9 +53,11 @@ import {
AlertTriangle,
Network,
Terminal,
+ Clock,
} from 'lucide-react';
import { StatusBadge } from '@/components/servers/StatusBadge';
import { displayStatus } from '@/lib/server-utils';
+import { formatDateTimeInTz, formatRelativeTime } from '@/lib/dateUtils';
import { ApiServer, Pagination, ApiNode, ApiAllocation } from '@/types/adminServerTypes';
import type { Server as ServerType } from '@/types/server';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from '@/components/ui/sheet';
@@ -75,6 +78,7 @@ import { Checkbox } from '@/components/ui/checkbox';
export default function ServersPage() {
const { t } = useTranslation();
+ const dateOpts = useDateFormatOptions();
const router = useRouter();
const [loading, setLoading] = useState(true);
@@ -97,6 +101,7 @@ export default function ServersPage() {
const [isAllocationModalOpen, setIsAllocationModalOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState(null);
const [selectedAllocation, setSelectedAllocation] = useState(null);
+ const [transferAutoAllocate, setTransferAutoAllocate] = useState(true);
const [nodesList, setNodesList] = useState([]);
const [allocationsList, setAllocationsList] = useState([]);
const [loadingNodes, setLoadingNodes] = useState(false);
@@ -342,6 +347,7 @@ export default function ServersPage() {
setTransferServer(server);
setSelectedNode(null);
setSelectedAllocation(null);
+ setTransferAutoAllocate(true);
setIsTransferDialogOpen(true);
};
@@ -386,14 +392,22 @@ export default function ServersPage() {
};
const initiateTransfer = async () => {
- if (!transferServer || !selectedNode || !selectedAllocation) return;
+ if (!transferServer || !selectedNode || (!transferAutoAllocate && !selectedAllocation)) return;
setIsInitiatingTransfer(true);
try {
- await axios.post(`/api/admin/servers/${transferServer.id}/transfer`, {
+ const payload: {
+ destination_node_id: number;
+ destination_allocation_id?: number;
+ auto_allocate?: boolean;
+ } = {
destination_node_id: selectedNode.id,
- destination_allocation_id: selectedAllocation.id,
- });
+ auto_allocate: transferAutoAllocate,
+ };
+ if (!transferAutoAllocate && selectedAllocation) {
+ payload.destination_allocation_id = selectedAllocation.id;
+ }
+ await axios.post(`/api/admin/servers/${transferServer.id}/transfer`, payload);
toast.success(t('admin.servers.messages.transfer_initiated'));
setIsTransferDialogOpen(false);
setRefreshKey((prev) => prev + 1);
@@ -787,6 +801,26 @@ export default function ServersPage() {
{formatDisk(server.disk)}
+ {server.owner?.last_seen && (
+
+
+
+ {t('admin.servers.owner_last_seen')}:{' '}
+
+ {formatRelativeTime(server.owner.last_seen, dateOpts)}
+
+
+
+ )}
}
actions={
@@ -983,17 +1017,41 @@ export default function ServersPage() {
+ {formatRelativeTime(
+ selectedServer.created_at,
+ dateOpts,
+ )}
+
+ ) : (
+ 'N/A'
+ )
}
/>
+ {formatRelativeTime(
+ selectedServer.updated_at,
+ dateOpts,
+ )}
+
+ ) : (
+ 'N/A'
+ )
}
/>
@@ -1095,6 +1153,16 @@ export default function ServersPage() {
title={t('admin.servers.details.labels.owner')}
name={selectedServer?.owner?.username}
detail={selectedServer?.owner?.email}
+ secondary={
+ selectedServer?.owner?.last_seen
+ ? `${t('admin.users.last_seen')}: ${formatRelativeTime(selectedServer.owner.last_seen, dateOpts)}`
+ : undefined
+ }
+ secondaryTitle={
+ selectedServer?.owner?.last_seen
+ ? formatDateTimeInTz(selectedServer.owner.last_seen, dateOpts)
+ : undefined
+ }
/>
-
-
- {
- if (selectedNode) {
- fetchAllocations(selectedNode.id);
- setIsAllocationModalOpen(true);
- } else {
- toast.error(t('admin.servers.transfer.select_node'));
- }
+
+
+ {!transferAutoAllocate ? (
+
+
+ {
+ if (selectedNode) {
+ fetchAllocations(selectedNode.id);
+ setIsAllocationModalOpen(true);
+ } else {
+ toast.error(t('admin.servers.transfer.select_node'));
+ }
+ }}
+ disabled={isInitiatingTransfer || !selectedNode}
>
- {selectedAllocation
- ? `${selectedAllocation.ip}:${selectedAllocation.port}`
- : t('admin.servers.transfer.select_allocation')}
-
-
-
-
+
+ {selectedAllocation
+ ? `${selectedAllocation.ip}:${selectedAllocation.port}`
+ : t('admin.servers.transfer.select_allocation')}
+
+
+
+
+ ) : null}
) : allocationsList.length === 0 ? (
- No free allocations found
+
+ {t('admin.servers.transfer.no_free_allocations')}
+
) : (
allocationsList.map((allc) => (
@@ -1879,6 +1977,11 @@ function RelationCard({
{name || 'N/A'}
{detail && {detail} }
+ {secondary && (
+
+ {secondary}
+
+ )}
);
}
diff --git a/frontendv2/src/app/(app)/admin/settings/page.tsx b/frontendv2/src/app/(app)/admin/settings/page.tsx
index c7c69fff3..8722a61ba 100644
--- a/frontendv2/src/app/(app)/admin/settings/page.tsx
+++ b/frontendv2/src/app/(app)/admin/settings/page.tsx
@@ -428,15 +428,15 @@ export default function SettingsPage() {
try {
const response = await axios.post('/api/admin/settings/email/test');
if (response.data.success) {
- toast.success(response.data.message || 'Test email sent successfully! Please check your inbox.');
+ toast.success(response.data.message || t('admin.settings.email_test.success'));
} else {
- toast.error(response.data.message || 'Failed to send test email');
+ toast.error(response.data.message || t('admin.settings.email_test.failed_short'));
}
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.data?.message) {
toast.error(error.response.data.message);
} else {
- toast.error('Failed to send test email. Please check your SMTP settings.');
+ toast.error(t('admin.settings.email_test.failed'));
}
} finally {
setSendingTestEmail(false);
diff --git a/frontendv2/src/app/(app)/admin/spells/[id]/edit/page.tsx b/frontendv2/src/app/(app)/admin/spells/[id]/edit/page.tsx
index 2c2f55c83..d210bf798 100644
--- a/frontendv2/src/app/(app)/admin/spells/[id]/edit/page.tsx
+++ b/frontendv2/src/app/(app)/admin/spells/[id]/edit/page.tsx
@@ -328,7 +328,7 @@ export default function EditSpellPage() {
- Loading spell...
+ {t('admin.spells.form.loading_spell')}
);
@@ -346,7 +346,7 @@ export default function EditSpellPage() {
router.push('/admin/spells')}>
- Back
+ {t('common.back')}
{t('admin.spells.form.submit_update')}
@@ -368,7 +368,7 @@ export default function EditSpellPage() {
-
+
@@ -426,7 +426,7 @@ export default function EditSpellPage() {
-
+
@@ -495,7 +495,7 @@ export default function EditSpellPage() {
-
+
@@ -526,7 +526,7 @@ export default function EditSpellPage() {
-
+
@@ -579,7 +579,7 @@ export default function EditSpellPage() {
-
+
diff --git a/frontendv2/src/app/(app)/admin/spells/create/page.tsx b/frontendv2/src/app/(app)/admin/spells/create/page.tsx
index 110fe0daf..3358008c7 100644
--- a/frontendv2/src/app/(app)/admin/spells/create/page.tsx
+++ b/frontendv2/src/app/(app)/admin/spells/create/page.tsx
@@ -93,7 +93,7 @@ export default function CreateSpellPage() {
const handleCreate = async () => {
if (!form.name || !form.realm_id) {
- toast.error('Name and Realm are required');
+ toast.error(t('admin.spells.messages.name_realm_required'));
return;
}
@@ -167,7 +167,7 @@ export default function CreateSpellPage() {
router.push('/admin/spells')}>
- Back
+ {t('common.back')}
{t('admin.spells.form.submit_create')}
@@ -188,7 +188,7 @@ export default function CreateSpellPage() {
-
+
@@ -260,7 +260,7 @@ export default function CreateSpellPage() {
-
+
@@ -330,7 +330,7 @@ export default function CreateSpellPage() {
-
+
@@ -362,7 +362,7 @@ export default function CreateSpellPage() {
-
+
@@ -415,7 +415,7 @@ export default function CreateSpellPage() {
-
+
diff --git a/frontendv2/src/app/(app)/admin/spells/page.tsx b/frontendv2/src/app/(app)/admin/spells/page.tsx
index a63dd2318..8238200f5 100644
--- a/frontendv2/src/app/(app)/admin/spells/page.tsx
+++ b/frontendv2/src/app/(app)/admin/spells/page.tsx
@@ -15,7 +15,7 @@ See the LICENSE file or .
'use client';
-import { useState, useEffect, useRef } from 'react';
+import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import axios, { isAxiosError } from 'axios';
import { useTranslation } from '@/contexts/TranslationContext';
@@ -45,6 +45,12 @@ import {
Trash2,
ChevronLeft,
ChevronRight,
+ ArrowUp,
+ ArrowDown,
+ Save,
+ X,
+ AlertCircle,
+ GripVertical,
Download,
Upload,
CloudDownload,
@@ -65,6 +71,7 @@ interface Spell {
uuid: string;
realm_id: number;
realm_name?: string;
+ sort_order?: number;
banner?: string;
update_url?: string;
created_at: string;
@@ -112,6 +119,9 @@ export default function SpellsPage() {
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importRealmId, setImportRealmId] = useState('');
const [importing, setImporting] = useState(false);
+ const [isReorderMode, setIsReorderMode] = useState(false);
+ const [reorderLoading, setReorderLoading] = useState(false);
+ const [hasOrderChanges, setHasOrderChanges] = useState(false);
const realmIdParam = searchParams?.get('realm_id');
@@ -261,13 +271,13 @@ export default function SpellsPage() {
const handleImportDialogSubmit = async () => {
if (!importRealmId) {
- toast.error('Please select a realm');
+ toast.error(t('admin.spells.messages.select_realm'));
return;
}
const file = (window as unknown as { __importFile?: File }).__importFile;
if (!file) {
- toast.error('No file selected');
+ toast.error(t('admin.spells.messages.no_file_selected'));
setImportDialogOpen(false);
return;
}
@@ -275,6 +285,94 @@ export default function SpellsPage() {
await performImport(file, importRealmId);
};
+ const fetchAllSpellsForReorder = useCallback(async (): Promise => {
+ if (!realmIdParam) return false;
+ const pageSize = 100;
+ try {
+ let page = 1;
+ const allSpells: Spell[] = [];
+ let totalRecords = 0;
+ while (true) {
+ const { data } = await axios.get('/api/admin/spells', {
+ params: {
+ page,
+ limit: pageSize,
+ realm_id: realmIdParam,
+ },
+ });
+ const pag = data.data.pagination;
+ totalRecords = pag.total_records;
+ const batch = (data.data.spells || []) as Spell[];
+ allSpells.push(...batch);
+ if (allSpells.length >= totalRecords || !pag.has_next) {
+ break;
+ }
+ page += 1;
+ }
+ const sorted = [...allSpells].sort(
+ (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.name.localeCompare(b.name),
+ );
+ setSpells(sorted);
+ return true;
+ } catch {
+ toast.error(t('admin.spells.messages.fetch_failed'));
+ return false;
+ }
+ }, [realmIdParam, t]);
+
+ const moveSpell = (spellId: number, direction: 'up' | 'down') => {
+ const index = spells.findIndex((s) => s.id === spellId);
+ if (index === -1) return;
+ const newIndex = direction === 'up' ? index - 1 : index + 1;
+ if (newIndex < 0 || newIndex >= spells.length) return;
+
+ const next = [...spells];
+ const [moved] = next.splice(index, 1);
+ next.splice(newIndex, 0, moved);
+ setSpells(
+ next.map((spell, idx) => ({
+ ...spell,
+ sort_order: idx * 10,
+ })),
+ );
+ setHasOrderChanges(true);
+ };
+
+ const saveSpellOrder = async () => {
+ if (!realmIdParam) return;
+ setReorderLoading(true);
+ try {
+ await axios.post('/api/admin/spells/reorder', {
+ realm_id: parseInt(realmIdParam, 10),
+ spells: spells.map((spell) => ({
+ id: spell.id,
+ sort_order: spell.sort_order ?? 0,
+ })),
+ });
+ toast.success(t('admin.spells.order.messages.saved'));
+ setHasOrderChanges(false);
+ setIsReorderMode(false);
+ setPagination((p) => ({ ...p, page: 1 }));
+ setRefreshKey((prev) => prev + 1);
+ } catch {
+ toast.error(t('admin.spells.order.messages.save_failed'));
+ } finally {
+ setReorderLoading(false);
+ }
+ };
+
+ const toggleReorderMode = async () => {
+ if (!isReorderMode) {
+ const ok = await fetchAllSpellsForReorder();
+ if (!ok) return;
+ } else {
+ setPagination((p) => ({ ...p, page: 1 }));
+ setRefreshKey((prev) => prev + 1);
+ setHasOrderChanges(false);
+ }
+ setIsReorderMode(!isReorderMode);
+ };
+
const subtitle = currentRealm
? t('admin.spells.subtitle_realm', { realm: currentRealm.name })
: t('admin.spells.subtitle');
@@ -287,53 +385,78 @@ export default function SpellsPage() {
description={subtitle}
icon={Sparkles}
actions={
-
- {currentRealm && (
- router.push('/admin/spells')}>
-
- {t('admin.spells.viewall')}
+
+ {currentRealm && !isReorderMode && (
+ <>
+
+
+
+ {t('admin.spells.order.title')}
+
+ router.push('/admin/spells')}>
+
+ {t('admin.spells.viewall')}
+
+ >
+ )}
+ {currentRealm && isReorderMode && (
+ <>
+ {hasOrderChanges && (
+
+
+ {t('common.save')}
+
+ )}
+
+
+ {t('common.cancel')}
+
+ >
+ )}
+ {!isReorderMode && (
+ router.push('/admin/feathercloud/spells')}>
+
+ {t('admin.spells.browse_marketplace')}
)}
- router.push('/admin/feathercloud/spells')}>
-
- {t('admin.spells.browse_marketplace')}
-
}
/>
-
-
-
- setSearchQuery(e.target.value)}
- className='h-11 w-full pl-10'
- />
-
-
- router.push('/admin/spells/create')}>
-
- {t('admin.spells.create')}
-
- fileInputRef.current?.click()}>
-
- {t('admin.spells.import')}
-
-
+ {!isReorderMode && (
+
+
+
+ setSearchQuery(e.target.value)}
+ className='h-11 w-full pl-10'
+ />
+
+
+ router.push('/admin/spells/create')}>
+
+ {t('admin.spells.create')}
+
+ fileInputRef.current?.click()}>
+
+ {t('admin.spells.import')}
+
+
+
-
+ )}
- {pagination.totalPages > 1 && !loading && (
+ {pagination.totalPages > 1 && !loading && !isReorderMode && (
router.push('/admin/spells/create')}>{t('admin.spells.create')}
}
/>
+ ) : isReorderMode ? (
+
+ {hasOrderChanges && (
+
+
+ {t('admin.spells.order.unsaved_changes')}
+
+ )}
+
+
+ {spells.map((spell, index) => (
+
+
+ {index + 1}
+
+
+ moveSpell(spell.id, 'up')}
+ disabled={index === 0}
+ >
+
+
+ moveSpell(spell.id, 'down')}
+ disabled={index === spells.length - 1}
+ >
+
+
+
+
+ {spell.name}
+ {spell.author ? (
+ {spell.author}
+ ) : null}
+
+
+ ))}
+
+
+
) : (
@@ -423,7 +595,7 @@ export default function SpellsPage() {
)}
- {pagination.totalPages > 1 && (
+ {pagination.totalPages > 1 && !isReorderMode && (
)}
-
-
- {t('admin.spells.help.cross_compatible.description')}
-
-
-
-
-
+ {!isReorderMode && (
+
- {t('admin.spells.help.what_are_spells.description')}
+ {t('admin.spells.help.cross_compatible.description')}
-
-
- {t('admin.spells.help.how_to_use.description')}
-
-
-
-
- {t('admin.spells.help.under_the_hood.description')}
-
-
-
-
- {t('admin.spells.help.sources.description')}
-
-
-
+ )}
+
+ {!isReorderMode && (
+
+
+
+ {t('admin.spells.help.what_are_spells.description')}
+
+
+
+
+ {t('admin.spells.help.how_to_use.description')}
+
+
+
+
+ {t('admin.spells.help.under_the_hood.description')}
+
+
+
+
+ {t('admin.spells.help.sources.description')}
+
+
+
+ )}
|