Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c40358a
chore: bump version to v1.3.7.3; add new features including plugin si…
NaysKutzu May 17, 2026
5ca1560
feat: add custom variable management to ServerUserController; impleme…
NaysKutzu May 17, 2026
e4d01a8
fix: update validation rules to include 'int' type for numeric checks…
NaysKutzu May 17, 2026
55ee445
feat: implement GDPR compliance system for user data export; enhance …
NaysKutzu May 17, 2026
caa475b
feat: implement per-user timezone preferences and per-server schedule…
NaysKutzu May 17, 2026
f4debd5
chore: update README with new file statistics; reflect changes in fil…
NaysKutzu May 17, 2026
80bfb59
feat: implement file trash functionality for server files; add endpoi…
NaysKutzu May 19, 2026
36aaf09
feat: enhance CloudPluginsController and PluginsPage to support store…
NaysKutzu May 19, 2026
2248ad1
feat: add Minecraft server configuration options and improve translat…
NaysKutzu May 19, 2026
74e5ed0
feat: implement detailed moderation tracking for user and server susp…
NaysKutzu May 19, 2026
0bad49f
feat: add branding configuration options and integrate "Powered by Fe…
NaysKutzu May 19, 2026
dec37ab
feat: enhance WingsTab with a new CommandBlock component for improved…
NaysKutzu May 19, 2026
b2cbfe4
feat: enhance allocation management in the admin area by adding pagin…
NaysKutzu May 20, 2026
51e1162
feat: add server switcher component to the navbar and sidebar for imp…
NaysKutzu May 20, 2026
0517e89
feat: implement mass server transfer functionality, allowing bulk mov…
NaysKutzu May 20, 2026
6d32d50
feat: implement spell reordering functionality within realms, allowin…
NaysKutzu May 20, 2026
db4f3bf
chore: update README and composer.lock for dependency versions and fi…
NaysKutzu May 20, 2026
0061a55
Merge branch 'main' into develop
NaysKutzu May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,19 @@ For installation instructions, system requirements, and complete guides, please

<!-- COUNT-STATS:START -->

_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 |

<!-- COUNT-STATS:END -->

Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@ TODO.txt
.install_mode
todo.txt
backend/storage/config/panel_integrity_baseline.json
build.log
build.log
crack/
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion app
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 14 additions & 5 deletions backend/app/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
188 changes: 188 additions & 0 deletions backend/app/Chat/Allocation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> $excludeIds Allocation IDs to skip (already reserved in the same batch)
*
* @return array<int>
*/
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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down
23 changes: 23 additions & 0 deletions backend/app/Chat/ChatConversation.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
class ChatConversation
{
private static string $table = 'featherpanel_chatbot_conversations';
private static ?array $columns = null;

/**
* Create a new conversation.
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
}
Loading
Loading