diff --git a/system/blueprints/config/system.yaml b/system/blueprints/config/system.yaml index 3eef8fc61..67c77362d 100644 --- a/system/blueprints/config/system.yaml +++ b/system/blueprints/config/system.yaml @@ -1614,6 +1614,14 @@ form: validate: type: bool + updates.staging_root: + type: text + label: PLUGIN_ADMIN.SAFE_UPGRADE_STAGING + help: PLUGIN_ADMIN.SAFE_UPGRADE_STAGING_HELP + placeholder: '/absolute/path/to/grav-upgrades' + validate: + type: string + http_section: type: section title: PLUGIN_ADMIN.HTTP_SECTION @@ -1929,4 +1937,3 @@ form: # pages.type: # type: hidden - diff --git a/system/config/system.yaml b/system/config/system.yaml index 87b6d4c1c..50cbcd28f 100644 --- a/system/config/system.yaml +++ b/system/config/system.yaml @@ -205,6 +205,7 @@ gpm: updates: safe_upgrade: true # Enable guarded staging+rollback pipeline for Grav self-updates + staging_root: '' # Optional absolute path for staging backups (default: /grav-upgrades) http: method: auto # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL diff --git a/system/languages/en.yaml b/system/languages/en.yaml index 3b34a93db..973199ff3 100644 --- a/system/languages/en.yaml +++ b/system/languages/en.yaml @@ -124,3 +124,5 @@ PLUGIN_ADMIN: UPDATES_SECTION: Updates SAFE_UPGRADE: Safe self-upgrade SAFE_UPGRADE_HELP: When enabled, Grav core updates use staged installation with automatic rollback support. + SAFE_UPGRADE_STAGING: Staging directory + SAFE_UPGRADE_STAGING_HELP: Optional absolute path for storing upgrade backups. Leave empty to use the default inside the parent directory. diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index 9346ce23a..026a9d07f 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -76,10 +76,12 @@ class SafeUpgradeService $this->rootPath = rtrim($root, DIRECTORY_SEPARATOR); $this->parentDir = $options['parent_dir'] ?? dirname($this->rootPath); $defaultStaging = $options['staging_root'] ?? ($this->parentDir . DIRECTORY_SEPARATOR . 'grav-upgrades'); - try { - Folder::create($defaultStaging); - } catch (\RuntimeException $e) { - throw new RuntimeException(sprintf('Unable to create staging directory at %s. Adjust permissions or configure system.updates.staging_root.', $defaultStaging)); + if (!$this->ensureWritable($defaultStaging)) { + $fallback = getenv('HOME') ? getenv('HOME') . DIRECTORY_SEPARATOR . 'grav-upgrades' : sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'grav-upgrades'; + if (!$this->ensureWritable($fallback)) { + throw new RuntimeException(sprintf('Unable to create staging directory at %s or fallback %s. Adjust permissions or configure system.updates.staging_root.', $defaultStaging, $fallback)); + } + $defaultStaging = $fallback; } $this->stagingRoot = realpath($defaultStaging) ?: $defaultStaging; $this->manifestStore = $options['manifest_store'] ?? ($this->rootPath . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'upgrades'); @@ -501,6 +503,27 @@ class SafeUpgradeService file_put_contents($target, json_encode($manifest, JSON_PRETTY_PRINT)); } + /** + * Ensure directory exists and is writable. + * + * @param string $path + * @return bool + */ + private function ensureWritable(string $path): bool + { + try { + Folder::create($path); + } catch (\RuntimeException $e) { + return false; + } + + if (!is_writable($path)) { + return false; + } + + return true; + } + /** * @param string|null $id * @return array|null