diff --git a/app/modules/web/Bootstrap.php b/app/modules/web/Bootstrap.php index 49f231cf..3730e84b 100644 --- a/app/modules/web/Bootstrap.php +++ b/app/modules/web/Bootstrap.php @@ -142,7 +142,7 @@ final class Bootstrap extends BootstrapBase $response->code(Code::INTERNAL_SERVER_ERROR->value); } - return $response->send(); + return $response->send(true); }; } @@ -178,6 +178,7 @@ final class Bootstrap extends BootstrapBase * @param Response $response * @return void * @throws SPException + * @throws Exception */ protected function buildResponse( ReflectionMethod $method, @@ -190,13 +191,21 @@ final class Bootstrap extends BootstrapBase static fn($_, ReflectionAttribute $item) => $item ); - $responseType = $attribute->newInstance()->responseType; - - if ($responseType === ResponseType::JSON) { - $this->response->header(Header::CONTENT_TYPE->value, Header::CONTENT_TYPE_JSON->value); - $response->body(ActionResponse::toJson($actionResponse)); - } elseif ($responseType === ResponseType::PLAIN_TEXT) { - $response->body(ActionResponse::toPlain($actionResponse)); + switch ($attribute->newInstance()->responseType) { + case ResponseType::JSON: + $this->response->header(Header::CONTENT_TYPE->value, Header::CONTENT_TYPE_JSON->value); + $response->body(ActionResponse::toJson($actionResponse)); + break; + case ResponseType::PLAIN_TEXT: + $response->body(ActionResponse::toPlain($actionResponse)); + break; + case ResponseType::CALLBACK: + if ($actionResponse->subject instanceof Closure) { + $actionResponse->subject->call($this, $response); + } + break; + default: + throw new Exception('Unexpected value'); } } } diff --git a/app/modules/web/Controllers/ConfigBackup/DownloadBackupAppController.php b/app/modules/web/Controllers/ConfigBackup/DownloadBackupAppController.php index ba59acbf..d85cf7c6 100644 --- a/app/modules/web/Controllers/ConfigBackup/DownloadBackupAppController.php +++ b/app/modules/web/Controllers/ConfigBackup/DownloadBackupAppController.php @@ -24,16 +24,18 @@ namespace SP\Modules\Web\Controllers\ConfigBackup; - -use Exception; +use Klein\Response; use SP\Core\Application; use SP\Core\Bootstrap\Path; use SP\Core\Bootstrap\PathsContext; use SP\Core\Context\Session; use SP\Core\Events\Event; use SP\Core\Events\EventMessage; +use SP\Domain\Common\Attributes\Action; +use SP\Domain\Common\Dtos\ActionResponse; +use SP\Domain\Common\Enums\ResponseStatus; +use SP\Domain\Common\Enums\ResponseType; use SP\Domain\Core\Acl\AclActionsInterface; -use SP\Domain\Core\Acl\UnauthorizedPageException; use SP\Domain\Core\Exceptions\SessionTimeout; use SP\Domain\Core\Exceptions\SPException; use SP\Domain\Export\Dtos\BackupFile; @@ -43,8 +45,8 @@ use SP\Modules\Web\Controllers\SimpleControllerBase; use SP\Modules\Web\Controllers\Traits\JsonTrait; use SP\Mvc\Controller\SimpleControllerHelper; +use function SP\__; use function SP\__u; -use function SP\processException; /** * Class DownloadBackupController @@ -63,56 +65,51 @@ final class DownloadBackupAppController extends SimpleControllerBase /** - * @return string + * @return ActionResponse */ - public function downloadBackupAppAction(): string + #[Action(ResponseType::CALLBACK)] + public function downloadBackupAppAction(): ActionResponse { if ($this->configData->isDemoEnabled()) { - return __('Ey, this is a DEMO!!'); + return ActionResponse::warning(__('Ey, this is a DEMO!!')); } - try { - Session::close(); + Session::close(); - $filePath = new BackupFile( - BackupType::app, - $this->configData->getBackupHash(), - $this->pathsContext[Path::BACKUP], - 'gz' - ); + $filePath = new BackupFile( + BackupType::app, + $this->configData->getBackupHash(), + $this->pathsContext[Path::BACKUP], + 'gz' + ); - $file = new FileHandler((string)$filePath); - $file->checkFileExists(); + $file = new FileHandler((string)$filePath); - $this->eventDispatcher->notify( - 'download.backupAppFile', - new Event( - $this, - EventMessage::build() - ->addDescription(__u('File downloaded')) - ->addDetail(__u('File'), str_replace(APP_ROOT, '', $file->getFile())) - ) - ); + $this->eventDispatcher->notify( + 'download.backupAppFile', + new Event( + $this, + EventMessage::build(__u('File downloaded')) + ->addDetail(__u('File'), str_replace(APP_ROOT, '', $file->getFile())) + ) + ); - $this->router - ->response() - ->header('Cache-Control', 'max-age=60, must-revalidate') - ->header('Content-length', $file->getFileSize()) - ->header('Content-type', $file->getFileType()) - ->header('Content-Description', ' sysPass file') - ->header('Content-transfer-encoding', 'chunked') - ->header('Content-Disposition', 'attachment; filename="' . basename($file->getFile()) . '"') - ->header('Set-Cookie', 'fileDownload=true; path=/') - ->send(); + return new ActionResponse( + ResponseStatus::OK, + function (Response $response) use ($file) { + $response + ->header('Cache-Control', 'max-age=60, must-revalidate') + ->header('Content-length', $file->getFileSize()) + ->header('Content-type', $file->getFileType()) + ->header('Content-Description', ' sysPass file') + ->header('Content-transfer-encoding', 'chunked') + ->header('Content-Disposition', 'attachment; filename="' . basename($file->getFile()) . '"') + ->header('Set-Cookie', 'fileDownload=true; path=/') + ->send(); - $file->readChunked(); - } catch (Exception $e) { - processException($e); - - $this->eventDispatcher->notify('exception', new Event($e)); - } - - return ''; + $file->readChunked(); + } + ); } /** @@ -123,13 +120,7 @@ final class DownloadBackupAppController extends SimpleControllerBase */ protected function initialize(): void { - try { - $this->checks(); - $this->checkAccess(AclActionsInterface::CONFIG_BACKUP); - } catch (UnauthorizedPageException $e) { - $this->eventDispatcher->notify('exception', new Event($e)); - - $this->returnJsonResponseException($e); - } + $this->checks(); + $this->checkAccess(AclActionsInterface::CONFIG_BACKUP); } } diff --git a/lib/SP/Domain/Common/Dtos/ActionResponse.php b/lib/SP/Domain/Common/Dtos/ActionResponse.php index 689e9a0f..c8454e12 100644 --- a/lib/SP/Domain/Common/Dtos/ActionResponse.php +++ b/lib/SP/Domain/Common/Dtos/ActionResponse.php @@ -26,6 +26,7 @@ declare(strict_types=1); namespace SP\Domain\Common\Dtos; +use Closure; use JsonSerializable; use SP\Domain\Common\Adapters\Serde; use SP\Domain\Common\Enums\ResponseStatus; @@ -42,7 +43,7 @@ final readonly class ActionResponse implements JsonSerializable public function __construct( public ResponseStatus $status, - public array|string $subject, + public array|string|Closure $subject, public array|string|stdClass|null $extra = null ) { } diff --git a/lib/SP/Domain/Common/Enums/ResponseType.php b/lib/SP/Domain/Common/Enums/ResponseType.php index 407b136b..55f2a1f8 100644 --- a/lib/SP/Domain/Common/Enums/ResponseType.php +++ b/lib/SP/Domain/Common/Enums/ResponseType.php @@ -32,4 +32,5 @@ enum ResponseType case PLAIN_TEXT; case JSON; case JSON_RPC; + case CALLBACK; } diff --git a/lib/SP/Infrastructure/File/FileHandler.php b/lib/SP/Infrastructure/File/FileHandler.php index 6935e8fa..961ff20b 100644 --- a/lib/SP/Infrastructure/File/FileHandler.php +++ b/lib/SP/Infrastructure/File/FileHandler.php @@ -189,9 +189,9 @@ final class FileHandler extends SplFileObject implements FileHandlerInterface while (!$this->eof()) { if ($chunker !== null) { - $chunker($this->fread(round($rate))); + $chunker($this->fread((int)round($rate))); } else { - print $this->fread(round($rate)); + print $this->fread((int)round($rate)); ob_flush(); flush(); } diff --git a/lib/SP/Util/Util.php b/lib/SP/Util/Util.php index 65c420aa..d9697843 100644 --- a/lib/SP/Util/Util.php +++ b/lib/SP/Util/Util.php @@ -112,7 +112,13 @@ final class Util public static function getMaxDownloadChunk(): int { - return self::convertShortUnit(ini_get('memory_limit')) / FileHandler::CHUNK_FACTOR; + $memoryLimit = ini_get('memory_limit'); + + if ($memoryLimit < 0) { + return 1024; + } + + return (int)(self::convertShortUnit(ini_get('memory_limit')) / FileHandler::CHUNK_FACTOR); } /** diff --git a/tests/SP/Modules/Web/Controllers/ConfigBackup/ConfigBackupControllerTest.php b/tests/SP/Modules/Web/Controllers/ConfigBackup/ConfigBackupControllerTest.php new file mode 100644 index 00000000..beb7ce95 --- /dev/null +++ b/tests/SP/Modules/Web/Controllers/ConfigBackup/ConfigBackupControllerTest.php @@ -0,0 +1,97 @@ +. + */ + +declare(strict_types=1); + +namespace SP\Tests\Modules\Web\Controllers\ConfigBackup; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\MockObject\Exception; +use PHPUnit\Framework\MockObject\Stub; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; +use SP\Domain\Config\Ports\ConfigDataInterface; +use SP\Domain\Core\Exceptions\InvalidClassException; +use SP\Infrastructure\File\FileException; +use SP\Tests\IntegrationTestCase; + +/** + * Class ConfigBackupControllerTest + */ +#[Group('integration')] +class ConfigBackupControllerTest extends IntegrationTestCase +{ + private array $definitions; + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + #[Test] + public function downloadBackupApp() + { + $filename = REAL_APP_ROOT . + DIRECTORY_SEPARATOR . + 'app' . + DIRECTORY_SEPARATOR . + 'backup' . + DIRECTORY_SEPARATOR . + 'sysPass_app-' . + $this->passwordSalt . + '.gz'; + + file_put_contents($filename, 'test_data'); + + $container = $this->buildContainer( + $this->definitions, + $this->buildRequest('get', 'index.php', ['r' => 'configBackup/downloadBackupApp']) + ); + + $this->runApp($container); + + $this->expectOutputString('test_data'); + } + + /** + * @throws InvalidClassException + * @throws FileException + */ + protected function setUp(): void + { + parent::setUp(); + + $this->definitions = $this->getModuleDefinitions(); + } + + protected function getConfigData(): ConfigDataInterface|Stub + { + $configData = parent::getConfigData(); + $configData->method('getBackupHash')->willReturn($this->passwordSalt); + + return $configData; + } + +}