Skip to content
Open
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
2 changes: 1 addition & 1 deletion formwork/config/system.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ authentication:

backup:
path: '${%ROOT_PATH%}/backup'
name: formwork-backup
name: '{{hostname}}-formwork-backup'
maxExecutionTime: 180
maxFiles: 10
ignore:
Expand Down
26 changes: 21 additions & 5 deletions formwork/src/Backup/Backupper.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@
namespace Formwork\Backup;

use Formwork\Backup\Utils\ZipErrors;
use Formwork\Cms\App;
use Formwork\Exceptions\TranslatedException;
use Formwork\Http\Request;
use Formwork\Utils\FileSystem;
use Formwork\Utils\Str;
use ZipArchive;

final class Backupper
{
/**
* Date format used in backup archive name
*/
private const string DATE_FORMAT = 'YmdHis';
private const string DATE_FORMAT = 'Ymd-His';

/**
* @param array<mixed> $options
* @param array<string, mixed> $options
*/
public function __construct(
private Request $request,
private array $options,
) {}

Expand All @@ -26,7 +30,7 @@ public function __construct(
*
* @return string Backup archive file path
*/
public function backup(): string
public function backup(?string $name = null, ?string $hostname = null): string
{
$previousMaxExecutionTime = ini_set('max_execution_time', $this->options['maxExecutionTime']);

Expand All @@ -37,9 +41,21 @@ public function backup(): string
FileSystem::createDirectory($this->options['path'], recursive: true);
}

$name = sprintf('%s-%s-%s.zip', str_replace([' ', '.'], '-', $this->options['hostname'] ?? 'unknown-host'), $this->options['name'], date(self::DATE_FORMAT));
$date = date(self::DATE_FORMAT);

$destination = FileSystem::joinPaths($path, $name);
$suffix = "-{$date}.zip";

$name = Str::interpolate($name ?? $this->options['name'], [
'hostname' => str_replace('.', '-', $hostname ?? $this->options['hostname'] ?? $this->request->host() ?? 'unknown-host'),
'site' => Str::slug(App::instance()->site()->title() ?? 'unknown-site'),
'context' => PHP_SAPI === 'cli' ? 'cli' : 'web',
'version' => App::VERSION,
'random' => FileSystem::randomName(),
]);

$filename = rtrim(substr($name, 0, 75 - strlen($suffix)), '-_') . $suffix;

Comment on lines +56 to +57
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$filename is derived from interpolated input and then passed into FileSystem::joinPaths($path, $filename). Because joinPaths normalizes .. segments, a name containing path separators (e.g. "../") can escape the configured backup directory. Sanitize the interpolated name to a safe basename (strip / and \, collapse .., and ideally restrict to a whitelist of filename characters) before joining, and consider rejecting/normalizing absolute paths.

Suggested change
$filename = rtrim(substr($name, 0, 75 - strlen($suffix)), '-_') . $suffix;
// Sanitize name to a safe filename: remove path separators and restrict characters
$safeName = preg_replace('/[\/\\\\]+/', '-', $name);
$safeName = preg_replace('/[^A-Za-z0-9._-]/', '-', $safeName);
$filename = rtrim(substr($safeName, 0, 75 - strlen($suffix)), '-_') . $suffix;

Copilot uses AI. Check for mistakes.
$destination = FileSystem::joinPaths($path, $filename);

$zipArchive = new ZipArchive();

Expand Down
8 changes: 8 additions & 0 deletions formwork/src/Cms/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use ErrorException;
use Formwork\Assets\Assets;
use Formwork\Authentication\Authenticator;
use Formwork\Backup\Backupper;
use Formwork\Cache\AbstractCache;
use Formwork\Cache\FilesCache;
use Formwork\Cms\Events\ExceptionThrownEvent;
Expand Down Expand Up @@ -50,6 +51,7 @@
use Formwork\Templates\Templates;
use Formwork\Traits\SingletonClass;
use Formwork\Translations\Translations;
use Formwork\Updater\Updater;
use Formwork\Users\UserFactory;
use Formwork\Users\Users;
use Formwork\Utils\Str;
Expand Down Expand Up @@ -360,6 +362,12 @@ private function loadServices(Container $container): void
$container->define(Plugins::class)
->loader(PluginsServiceLoader::class)
->alias('plugins');

$container->define(Backupper::class)
->parameter('options', fn(Config $config) => $config->get('system.backup'));

$container->define(Updater::class)
->parameter('options', fn(Config $config) => $config->get('system.updates'));
}

/**
Expand Down
14 changes: 3 additions & 11 deletions formwork/src/Commands/BackupCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ public function make(array $argv = []): void
{
$this->climate->out('Creating backup... this may take a while depending on the size of your installation.');
/** @var string $hostname */
$hostname = $this->climate->arguments->get('hostname') ?: null;
$file = $this->getBackupper($hostname)->backup();
$hostname = $this->climate->arguments->get('hostname') ?: (gethostname() ?: 'local-cli');
$file = $this->app->getService(Backupper::class)->backup(hostname: $hostname);
$this->climate->br()->out(sprintf('<green>Backup created:</green> %s', $file));
}

Expand All @@ -116,7 +116,7 @@ public function make(array $argv = []): void
*/
public function list(array $argv = []): void
{
$backups = $this->getBackupper()->getBackups();
$backups = $this->app->getService(Backupper::class)->getBackups();
if (count($backups) === 0) {
$this->climate->green('No backups found.');
return;
Expand All @@ -131,14 +131,6 @@ public function list(array $argv = []): void
}
}

/**
* Get Backupper instance
*/
private function getBackupper(?string $hostname = null): Backupper
{
return new Backupper([...$this->app->config()->get('system.backup'), 'hostname' => $hostname ?? (gethostname() ?: 'local-cli')]);
}

/**
* @param list<string> $argv
*/
Expand Down
36 changes: 7 additions & 29 deletions formwork/src/Commands/UpdatesCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,10 @@ public function check(array $argv = []): void
{
$force = $this->climate->arguments->defined('force');

$updater = $this->getUpdater(['force' => $force]);
$updater = $this->app->getService(Updater::class);

try {
$upToDate = $updater->checkUpdates();
$upToDate = $updater->checkUpdates($force);
} catch (RuntimeException $e) {
$this->climate->error("Cannot check for updates: {$e->getMessage()}");
exit(1);
Expand Down Expand Up @@ -158,14 +158,10 @@ public function update(array $argv = []): void
$backup = !$this->climate->arguments->defined('no-backup');
$preferDist = !$this->climate->arguments->defined('no-prefer-dist');
$cleanup = !$this->climate->arguments->defined('no-cleanup');
$updater = $this->getUpdater([
'force' => $force,
'preferDistAssets' => $preferDist,
'cleanupAfterInstall' => $cleanup,
]);
$updater = $this->app->getService(Updater::class);

try {
$upToDate = $updater->checkUpdates();
$upToDate = $updater->checkUpdates(force: $force, preferDistAssets: $preferDist);
} catch (RuntimeException $e) {
$this->climate->error("Cannot check for updates: {$e->getMessage()}");
exit(1);
Expand All @@ -184,9 +180,9 @@ public function update(array $argv = []): void

if ($backup) {
$this->climate->out('Creating backup before update... this may take a while depending on the size of your installation and site.');
$backupper = $this->getBackupper();
try {
$backupper->backup();
$this->app->getService(Backupper::class)
->backup(hostname: gethostname() ?: 'local-cli');
} catch (RuntimeException $e) {
$this->climate->error("Cannot make backup: {$e->getMessage()}");
exit(1);
Expand All @@ -196,7 +192,7 @@ public function update(array $argv = []): void

try {
$this->climate->out("Updating <bold>Formwork</bold> to <bold><green>{$release['tag']}</green></bold>...");
$updater->update();
$updater->update(force: $force, preferDistAssets: $preferDist, cleanupAfterInstall: $cleanup);
} catch (RuntimeException $e) {
$this->climate->error("Cannot install updates: {$e->getMessage()}");
exit(1);
Expand All @@ -212,24 +208,6 @@ public function update(array $argv = []): void
$this->climate->out("<bold>Formwork</bold> has been updated successfully to <bold><green>{$release['tag']}</green></bold>.");
}

/**
* Get Updater instance
*
* @param array<string, mixed> $config
*/
private function getUpdater(array $config): Updater
{
return new Updater([...$this->app->config()->get('system.updates'), ...$config], App::instance());
}

/**
* Get Backupper instance
*/
private function getBackupper(): Backupper
{
return new Backupper([...$this->app->config()->get('system.backup'), 'hostname' => gethostname() ?: 'local-cli']);
}

/**
* @param list<string> $argv
*/
Expand Down
3 changes: 1 addition & 2 deletions formwork/src/Panel/Controllers/BackupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,12 @@ final class BackupController extends AbstractController
/**
* Backup@make action
*/
public function make(): JsonResponse|Response
public function make(Backupper $backupper): JsonResponse|Response
{
if (!$this->hasPermission('panel.backup.make')) {
return $this->forward(ErrorsController::class, 'forbidden');
}

$backupper = $backupper = new Backupper([...$this->config->get('system.backup'), 'hostname' => $this->request->host()]);
try {
$file = $backupper->backup();
} catch (TranslatedException $e) {
Expand Down
4 changes: 1 addition & 3 deletions formwork/src/Panel/Controllers/ToolsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,12 @@ public function index(): Response
/**
* Tools@backups action
*/
public function backups(): Response
public function backups(Backupper $backupper): Response
{
if (!$this->hasPermission('panel.tools.backups')) {
return $this->forward(ErrorsController::class, 'forbidden');
}

$backupper = new Backupper([...$this->config->get('system.backup'), 'hostname' => $this->request->host()]);

$backups = Arr::map($backupper->getBackups(), fn(string $path, int $timestamp): array => [
'name' => basename($path),
'encodedName' => rawurlencode(base64_encode(basename($path))),
Expand Down
3 changes: 1 addition & 2 deletions formwork/src/Panel/Controllers/UpdatesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,13 @@ public function check(Updater $updater): JsonResponse|Response
/**
* Updates@update action
*/
public function update(Updater $updater, AbstractCache $cache): JsonResponse|Response
public function update(Updater $updater, Backupper $backupper, AbstractCache $cache): JsonResponse|Response
{
if (!$this->hasPermission('panel.updates.update')) {
return $this->forward(ErrorsController::class, 'forbidden');
}

if ($this->config->get('system.updates.backupBefore')) {
$backupper = new Backupper([...$this->config->get('system.backup'), 'hostname' => $this->request->host()]);
try {
$backupper->backup();
} catch (TranslatedException) {
Expand Down
4 changes: 0 additions & 4 deletions formwork/src/Services/Loaders/PanelServiceLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
use Formwork\Services\Container;
use Formwork\Services\ResolutionAwareServiceLoaderInterface;
use Formwork\Translations\Translations;
use Formwork\Updater\Updater;
use Formwork\Utils\FileSystem;
use Formwork\View\ViewFactory;

Expand Down Expand Up @@ -56,9 +55,6 @@ public function load(Container $container): Panel
$container->resolve(RateLimiter::class);
}

$container->define(Updater::class)
->parameter('options', $this->config->get('system.updates'));

if ($this->config->has('system.panel.sessionTimeout')) {
trigger_error('The "system.panel.sessionTimeout" configuration option (in minutes) is deprecated since Formwork 2.3.0. Use "system.session.duration" (in seconds) instead.', E_USER_DEPRECATED);
$this->request->session()->setDuration($this->config->get('system.panel.sessionTimeout') * 60);
Expand Down
19 changes: 9 additions & 10 deletions formwork/src/Updater/Updater.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ final class Updater
*/
public function __construct(
private array $options,
private App $app,
) {
$this->registry = new Registry($this->options['registryFile']);

Expand All @@ -83,18 +82,18 @@ public function __construct(
*
* @return bool Whether updates are found or not
*/
public function checkUpdates(): bool
public function checkUpdates(?bool $force = null, ?bool $preferDistAssets = null): bool
{
if (
!$this->options['force']
!($force ?? $this->options['force'])
&& $this->registry->has('currentRelease') && $this->registry->get('currentRelease') === App::VERSION
&& $this->registry->has('lastCheck') && time() - $this->registry->get('lastCheck') < $this->options['time']
) {
$this->release = $this->registry->get('release');
return $this->registry->get('upToDate');
}

$this->loadRelease();
$this->loadRelease($preferDistAssets ?? $this->options['preferDistAssets']);

$this->registry->set('lastCheck', time());
$this->registry->set('currentRelease', App::VERSION);
Expand Down Expand Up @@ -128,9 +127,9 @@ public function checkUpdates(): bool
*
* @return bool|null Whether Formwork was updated or not
*/
public function update(): ?bool
public function update(?bool $force = null, ?bool $preferDistAssets = null, ?bool $cleanupAfterInstall = null): ?bool
{
$this->checkUpdates();
$this->checkUpdates($force, $preferDistAssets);

if ($this->registry->get('upToDate')) {
return null;
Expand Down Expand Up @@ -178,7 +177,7 @@ public function update(): ?bool

FileSystem::delete($this->options['tempFile']);

if ($this->options['cleanupAfterInstall']) {
if ($cleanupAfterInstall ?? $this->options['cleanupAfterInstall']) {
$deletableFiles = $this->findDeletableFiles($installedFiles);
foreach ($deletableFiles as $deletableFile) {
FileSystem::delete($deletableFile);
Expand Down Expand Up @@ -208,7 +207,7 @@ public function latestRelease(): ?array
/**
* Load latest release data
*/
private function loadRelease(): void
private function loadRelease(bool $preferDistAssets = true): void
{
if (isset($this->release)) {
return;
Expand All @@ -233,7 +232,7 @@ private function loadRelease(): void
'archive' => $data['zipball_url'],
];

if ($this->options['preferDistAssets'] && !empty($data['assets'])) {
if ($preferDistAssets && !empty($data['assets'])) {
$assetName = "formwork-{$data['tag_name']}.zip";
$key = array_search($assetName, array_column($data['assets'], 'name'), true);

Expand Down Expand Up @@ -266,7 +265,7 @@ private function getReleaseArchiveEtag(): string
*/
private function isVersionInstallable(string $version): bool
{
$semVer = SemVer::fromString($this->app::VERSION);
$semVer = SemVer::fromString($this->registry->get('currentRelease'));
$new = SemVer::fromString($version);
return !$new->isPrerelease() && $semVer->compareWith($new, '!=') && $semVer->compareWith($new, '^');
}
Expand Down