Skip to content
Merged

ye #197

Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## v1.3.7.6 STABLE

### Fixed

- Codemirror crash on the code editor page was fixed. by @nayskutzu
- File manager downloads now use signed Wings URLs (same as backups) instead of proxying the whole file through PHP, fixing large-file failures caused by the default 30s Guzzle/cURL timeout. by @cursor
- PBS backups no longer show **0 MiB** when snapshot-list size is missing: Wings parses logical size from `proxmox-backup-client` output. New PBS archives default to `root.pxar` (PVE-style `root.pxar.didx` in PBS UI); restore still accepts legacy `server.pxar`. by @cursor

## v1.3.7.5 STABLE

### Added
Expand Down Expand Up @@ -32,6 +40,7 @@
### Fixed

- Cloudflare Under Attack Mode / challenge HTML no longer clears the panel session or empties permissions (admin appearing as a normal user, owned-server access errors, console JWT failures). by @cursor
- Marketplace/cloud `.fpa` installs failing with `ADDON_EXTRACT_FAILED`: Mythic packages use AES-256 (PKZIP 5.1) while Info-ZIP `unzip` only supports ZipCrypto — extraction now uses PHP `ZipArchive` (with unzip fallback). by @cursor
- Admin area stats were not loading correctly. by @nayskutzu
- Fixed the issue with unlimited backup limit. by @nayskutzu
- Issues related to port allocation were fixed. by @nayskutzu
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.5');
define('APP_VERSION', 'v1.3.7.6');
define('APP_UPSTREAM', 'stable');
define('TELEMETRY', true);
define('IS_CLI', true);
Expand Down
17 changes: 6 additions & 11 deletions backend/app/Controllers/Admin/CloudPluginsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use App\Chat\InstalledPlugin;
use OpenApi\Attributes as OA;
use App\Helpers\PanelAssetUrl;
use App\Helpers\AddonPackageHelper;
use App\CloudFlare\CloudFlareRealIP;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
Expand Down Expand Up @@ -904,14 +905,11 @@ public function install(Request $request): Response
$tempFile = sys_get_temp_dir() . '/' . uniqid('featherpanel_', true) . '.fpa';
file_put_contents($tempFile, $fileContent);

// Extract
// Extract (ZipArchive: AES marketplace packages + legacy ZipCrypto)
$tempDir = sys_get_temp_dir() . '/' . uniqid('featherpanel_', true);
@mkdir($tempDir, 0755, true);
$pwd = self::PASSWORD;
$unzipCommand = sprintf('unzip -P %s %s -d %s', escapeshellarg($pwd), escapeshellarg($tempFile), escapeshellarg($tempDir));
exec($unzipCommand, $out, $code);
$extracted = AddonPackageHelper::extract($tempFile, $tempDir, self::PASSWORD);
@unlink($tempFile);
if ($code !== 0) {
if (!$extracted) {
@exec('rm -rf ' . escapeshellarg($tempDir));

return ApiResponse::error('Failed to extract addon package', 'ADDON_EXTRACT_FAILED', 422);
Expand Down Expand Up @@ -1330,12 +1328,9 @@ private function evaluateConfDependencyChecksFromBinary(string $fileContent): ar
file_put_contents($tempFile, $fileContent);

$tempDir = sys_get_temp_dir() . '/' . uniqid('featherpanel_check_', true);
@mkdir($tempDir, 0755, true);
$pwd = self::PASSWORD;
$unzipCommand = sprintf('unzip -P %s %s conf.yml -d %s', escapeshellarg($pwd), escapeshellarg($tempFile), escapeshellarg($tempDir));
exec($unzipCommand, $out, $code);
$extracted = AddonPackageHelper::extract($tempFile, $tempDir, self::PASSWORD, ['conf.yml']);

if ($code === 0 && file_exists($tempDir . '/conf.yml')) {
if ($extracted && file_exists($tempDir . '/conf.yml')) {
try {
$conf = \Symfony\Component\Yaml\Yaml::parseFile($tempDir . '/conf.yml');
$confDependencies = $conf['plugin']['dependencies'] ?? [];
Expand Down
17 changes: 6 additions & 11 deletions backend/app/Controllers/Admin/PluginsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use App\Chat\InstalledPlugin;
use App\Plugins\PluginConfig;
use OpenApi\Attributes as OA;
use App\Helpers\AddonPackageHelper;
use App\Helpers\PanelAssetUrl;
use App\Config\ConfigInterface;
use App\Plugins\PluginSettings;
Expand Down Expand Up @@ -742,14 +743,11 @@ public function uploadInstall(Request $request): Response
$tempFile = sys_get_temp_dir() . '/' . uniqid('featherpanel_', true) . '.fpa';
$file->move(dirname($tempFile), basename($tempFile));

// Extract
// Extract (ZipArchive: AES marketplace packages + legacy ZipCrypto)
$tempDir = sys_get_temp_dir() . '/' . uniqid('featherpanel_', true);
@mkdir($tempDir, 0755, true);
$pwd = CloudPluginsController::PASSWORD;
$unzipCommand = sprintf('unzip -P %s %s -d %s', escapeshellarg($pwd), escapeshellarg($tempFile), escapeshellarg($tempDir));
exec($unzipCommand, $out, $code);
$extracted = AddonPackageHelper::extract($tempFile, $tempDir, CloudPluginsController::PASSWORD);
@unlink($tempFile);
if ($code !== 0) {
if (!$extracted) {
@exec('rm -rf ' . escapeshellarg($tempDir));

return ApiResponse::error('Failed to extract addon package', 'ADDON_EXTRACT_FAILED', 422);
Expand Down Expand Up @@ -818,12 +816,9 @@ public function uploadInstallFromUrl(Request $request): Response
file_put_contents($tempFile, $fileContent);

$tempDir = sys_get_temp_dir() . '/' . uniqid('featherpanel_', true);
@mkdir($tempDir, 0755, true);
$pwd = CloudPluginsController::PASSWORD;
$unzipCommand = sprintf('unzip -P %s %s -d %s', escapeshellarg($pwd), escapeshellarg($tempFile), escapeshellarg($tempDir));
exec($unzipCommand, $out, $code);
$extracted = AddonPackageHelper::extract($tempFile, $tempDir, CloudPluginsController::PASSWORD);
@unlink($tempFile);
if ($code !== 0) {
if (!$extracted) {
@exec('rm -rf ' . escapeshellarg($tempDir));

return ApiResponse::error('Failed to extract addon package', 'ADDON_EXTRACT_FAILED', 422);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
use App\SubuserPermissions;
use App\Chat\ServerActivity;
use App\Helpers\ApiResponse;
use App\Helpers\AppUrlHelper;
use App\Helpers\WingsUrlHelper;
use App\Services\Wings\Wings;
use App\Services\Wings\Services\JwtService;
use OpenApi\Attributes as OA;
use App\Config\ConfigInterface;
use App\Plugins\Events\Events\ServerEvent;
Expand Down Expand Up @@ -2107,8 +2110,8 @@ public function uploadFile(Request $request, string $serverUuid): Response

#[OA\Get(
path: '/api/user/servers/{uuidShort}/download-file',
summary: 'Download file',
description: 'Download a file from the server with appropriate headers for file download.',
summary: 'Get file download URL',
description: 'Generate a secure one-time Wings download URL for a server file with JWT token authentication (same pattern as backup downloads).',
tags: ['User - Server Files'],
parameters: [
new OA\Parameter(
Expand All @@ -2129,39 +2132,19 @@ public function uploadFile(Request $request, string $serverUuid): Response
responses: [
new OA\Response(
response: 200,
description: 'File downloaded successfully',
content: new OA\MediaType(
mediaType: 'application/octet-stream',
schema: new OA\Schema(type: 'string', format: 'binary')
),
headers: [
new OA\Header(
header: 'Content-Type',
description: 'MIME type of the file',
schema: new OA\Schema(type: 'string', example: 'text/plain')
),
new OA\Header(
header: 'Content-Disposition',
description: 'Download attachment header',
schema: new OA\Schema(type: 'string', example: 'attachment; filename="file.txt"')
),
new OA\Header(
header: 'Content-Length',
description: 'File size in bytes',
schema: new OA\Schema(type: 'string', example: '1024')
),
new OA\Header(
header: 'Cache-Control',
description: 'Cache control header',
schema: new OA\Schema(type: 'string', example: 'no-cache, no-store, must-revalidate')
),
]
description: 'Download URL generated successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'download_url', type: 'string', description: 'Signed Wings file download URL'),
new OA\Property(property: 'expires_in', type: 'integer', description: 'Token lifetime in seconds', example: 300),
]
)
),
new OA\Response(response: 400, description: 'Bad request - Missing UUID or file path'),
new OA\Response(response: 401, description: 'Unauthorized - User not authenticated'),
new OA\Response(response: 403, description: 'Forbidden - Access denied to server'),
new OA\Response(response: 404, description: 'Not found - Server, node, or file not found'),
new OA\Response(response: 500, description: 'Internal server error - Failed to download file'),
new OA\Response(response: 500, description: 'Internal server error - Failed to generate download URL'),
]
)]
public function downloadFile(Request $request, string $serverUuid): Response
Expand All @@ -2183,56 +2166,30 @@ public function downloadFile(Request $request, string $serverUuid): Response
}

$node = $this->validateNode($server['node_id']);
$filename = basename($path);

$wings = $this->createWingsConnection($node);

// Use the download method to get raw file content
$response = $wings->getServer()->downloadFile($server['uuid'], $path);

if (!$response->isSuccessful()) {
$error = $response->getError();

return ApiResponse::error('Failed to download file: ' . $error, 'WINGS_ERROR', $response->getStatusCode());
}

// Get the raw file content
$fileContent = $response->getRawBody();

// Empty files are valid - only return error if content is null (indicating an actual error)
// If fileContent is null (not just empty string), it might indicate an error
if ($fileContent === null) {
// Check if response indicates an error
$responseData = $response->getData();
if (is_array($responseData) && (isset($responseData['error']) || isset($responseData['error_message']))) {
return ApiResponse::error('File content could not be retrieved', 'FILE_CONTENT_ERROR', 500);
}
// If no error indicated, treat as empty file
$fileContent = '';
}
$token = $node['daemon_token'];
$wingsBaseUrl = WingsUrlHelper::buildFromNode($node);

// Get filename from path
$filename = basename($path);
$jwtService = new JwtService(
$token,
AppUrlHelper::wingsRemoteUrl(),
$wingsBaseUrl
);

// Determine content type based on file extension
$contentType = $this->getMimeType($path);
$jwtToken = $jwtService->generateFileDownloadToken($server['uuid'], $path);
$baseUrl = rtrim($wingsBaseUrl, '/');
$encodedFilePath = urlencode($path);
$downloadUrl = "{$baseUrl}/download/file?token={$jwtToken}&server={$server['uuid']}&file={$encodedFilePath}";

// Log activity
$this->logActivity($server, $node, 'file_downloaded', [
'path' => $path,
'filename' => $filename,

'file_size' => strlen($fileContent),
'content_type' => $contentType,
], $user);

// Return file content with download headers
return new Response($fileContent, 200, [
'Content-Type' => $contentType,
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Length' => strlen($fileContent),
'Cache-Control' => 'no-cache, no-store, must-revalidate',
'Pragma' => 'no-cache',
'Expires' => '0',
return ApiResponse::success([
'download_url' => $downloadUrl,
'expires_in' => 300,
]);
} catch (\Exception $e) {
return $this->handleWingsError($e, 'download file');
Expand Down
104 changes: 104 additions & 0 deletions backend/app/Helpers/AddonPackageHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

/*
* This file is part of FeatherPanel.
*
* Copyright (C) 2025 MythicalSystems Studios
* Copyright (C) 2025 FeatherPanel Contributors
* Copyright (C) 2025 Cassian Gherman (aka NaysKutzu)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* See the LICENSE file or <https://www.gnu.org/licenses/>.
*/

namespace App\Helpers;

/**
* Extract password-protected .fpa addon packages.
*
* Marketplace packages from Mythic are re-encrypted with AES-256 (PKZIP 5.1).
* Info-ZIP `unzip` only supports traditional ZipCrypto (PKZIP ≤ 4.6), so extraction
* must use PHP ZipArchive (libzip), which handles both AES and ZipCrypto.
*/
class AddonPackageHelper
{
/**
* Extract a .fpa archive into a destination directory.
*
* @param string $archivePath Path to the .fpa / zip file
* @param string $destinationDir Directory to extract into (created if missing)
* @param string $password Archive password
* @param list<string>|null $entries Optional subset of entries to extract (e.g. ['conf.yml'])
*/
public static function extract(string $archivePath, string $destinationDir, string $password, ?array $entries = null): bool
{
if (!is_file($archivePath)) {
return false;
}

if (!is_dir($destinationDir) && !@mkdir($destinationDir, 0755, true)) {
return false;
}

if (self::extractWithZipArchive($archivePath, $destinationDir, $password, $entries)) {
return true;
}

// Fallback for legacy ZipCrypto packages if ZipArchive is unavailable/broken
return self::extractWithUnzip($archivePath, $destinationDir, $password, $entries);
}

/**
* @param list<string>|null $entries
*/
private static function extractWithZipArchive(string $archivePath, string $destinationDir, string $password, ?array $entries): bool
{
if (!class_exists(\ZipArchive::class)) {
return false;
}

$zip = new \ZipArchive();
if ($zip->open($archivePath) !== true) {
return false;
}

if ($password !== '') {
$zip->setPassword($password);
}

$ok = $entries === null || $entries === []
? $zip->extractTo($destinationDir)
: $zip->extractTo($destinationDir, $entries);

$zip->close();

return $ok === true;
}

/**
* @param list<string>|null $entries
*/
private static function extractWithUnzip(string $archivePath, string $destinationDir, string $password, ?array $entries): bool
{
$entryArgs = '';
if ($entries !== null && $entries !== []) {
$entryArgs = ' ' . implode(' ', array_map('escapeshellarg', $entries));
}

$unzipCommand = sprintf(
'unzip -P %s %s%s -d %s',
escapeshellarg($password),
escapeshellarg($archivePath),
$entryArgs,
escapeshellarg($destinationDir)
);

exec($unzipCommand, $out, $code);

return $code === 0;
}
}
15 changes: 15 additions & 0 deletions backend/app/Services/Wings/Services/JwtService.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,21 @@ public function generateBackupToken(
);
}

/**
* Generate a one-time JWT token for direct Wings file downloads.
*
* @param string $serverUuid The server UUID
* @param string $filePath The file path on the server
*
* @throws \Exception
*
* @return string The JWT token
*/
public function generateFileDownloadToken(string $serverUuid, string $filePath): string
{
return $this->tokenGenerator->generateFileDownloadToken($serverUuid, $filePath);
}

/**
* Generate a JWT token for file operations.
*
Expand Down
2 changes: 1 addition & 1 deletion backend/cli
Original file line number Diff line number Diff line change
Expand Up @@ -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.5');
define('APP_VERSION', 'v1.3.7.6');
define('APP_UPSTREAM', 'stable');
define('REQUEST_ID', uniqid());
define('TELEMETRY', true);
Expand Down
2 changes: 1 addition & 1 deletion backend/public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.5');
define('APP_VERSION', 'v1.3.7.6');
define('APP_UPSTREAM', 'stable');
define('REQUEST_ID', uniqid());

Expand Down
Loading
Loading