diff --git a/.env b/.env index 9a6ce846..3ba3d65d 100644 --- a/.env +++ b/.env @@ -59,6 +59,17 @@ ERROR_PAGE_ADMIN_EMAIL='' # If this is set to true, solutions to common problems are shown on error pages. Disable this, if you do not want your users to see them... ERROR_PAGE_SHOW_HELP=1 +################################################################################### +# Update Manager settings +################################################################################### + +# Disable web-based updates from the Update Manager UI (0=enabled, 1=disabled). +# When disabled, use the CLI command "php bin/console partdb:update" instead. +DISABLE_WEB_UPDATES=1 + +# Disable backup restore from the Update Manager UI (0=enabled, 1=disabled). +# Restoring backups is a destructive operation that could overwrite your database. +DISABLE_BACKUP_RESTORE=1 ################################################################################### # SAML Single sign on-settings diff --git a/VERSION b/VERSION index 73462a5a..8f87ff7d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.1 +2.6.0-dev diff --git a/assets/controllers/backup_restore_controller.js b/assets/controllers/backup_restore_controller.js new file mode 100644 index 00000000..85ee327b --- /dev/null +++ b/assets/controllers/backup_restore_controller.js @@ -0,0 +1,55 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics) + * + * 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. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Controller } from '@hotwired/stimulus'; + +/** + * Stimulus controller for backup restore confirmation dialogs. + * Shows a confirmation dialog with backup details before allowing restore. + */ +export default class extends Controller { + static values = { + filename: { type: String, default: '' }, + date: { type: String, default: '' }, + confirmTitle: { type: String, default: 'Restore Backup' }, + confirmMessage: { type: String, default: 'Are you sure you want to restore from this backup?' }, + confirmWarning: { type: String, default: 'This will overwrite your current database. This action cannot be undone!' }, + }; + + connect() { + this.element.addEventListener('submit', this.handleSubmit.bind(this)); + } + + handleSubmit(event) { + // Always prevent default first + event.preventDefault(); + + // Build confirmation message + const message = this.confirmTitleValue + '\n\n' + + 'Backup: ' + this.filenameValue + '\n' + + 'Date: ' + this.dateValue + '\n\n' + + this.confirmMessageValue + '\n\n' + + '⚠️ ' + this.confirmWarningValue; + + // Only submit if user confirms + if (confirm(message)) { + this.element.submit(); + } + } +} diff --git a/assets/controllers/update_confirm_controller.js b/assets/controllers/update_confirm_controller.js new file mode 100644 index 00000000..c30a433e --- /dev/null +++ b/assets/controllers/update_confirm_controller.js @@ -0,0 +1,81 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics) + * + * 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. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Controller } from '@hotwired/stimulus'; + +/** + * Stimulus controller for update/downgrade confirmation dialogs. + * Intercepts form submission and shows a confirmation dialog before proceeding. + */ +export default class extends Controller { + static values = { + isDowngrade: { type: Boolean, default: false }, + targetVersion: { type: String, default: '' }, + confirmUpdate: { type: String, default: 'Are you sure you want to update Part-DB?' }, + confirmDowngrade: { type: String, default: 'Are you sure you want to downgrade Part-DB?' }, + downgradeWarning: { type: String, default: 'WARNING: This version does not include the Update Manager.' }, + minUpdateManagerVersion: { type: String, default: '2.6.0' }, + }; + + connect() { + this.element.addEventListener('submit', this.handleSubmit.bind(this)); + } + + handleSubmit(event) { + // Always prevent default first + event.preventDefault(); + + const targetClean = this.targetVersionValue.replace(/^v/, ''); + let message; + + if (this.isDowngradeValue) { + // Check if downgrading to a version without Update Manager + if (this.compareVersions(targetClean, this.minUpdateManagerVersionValue) < 0) { + message = this.confirmDowngradeValue + '\n\n⚠️ ' + this.downgradeWarningValue; + } else { + message = this.confirmDowngradeValue; + } + } else { + message = this.confirmUpdateValue; + } + + // Only submit if user confirms + if (confirm(message)) { + // Remove the event listener to prevent infinite loop, then submit + this.element.removeEventListener('submit', this.handleSubmit.bind(this)); + this.element.submit(); + } + } + + /** + * Compare two version strings (e.g., "2.5.0" vs "2.6.0") + * Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2 + */ + compareVersions(v1, v2) { + const parts1 = v1.split('.').map(Number); + const parts2 = v2.split('.').map(Number); + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const p1 = parts1[i] || 0; + const p2 = parts2[i] || 0; + if (p1 < p2) return -1; + if (p1 > p2) return 1; + } + return 0; + } +} diff --git a/composer.json b/composer.json index f7a181a8..8ce686c2 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "ext-intl": "*", "ext-json": "*", "ext-mbstring": "*", + "ext-zip": "*", "amphp/http-client": "^5.1", "api-platform/doctrine-orm": "^4.1", "api-platform/json-api": "^4.0.0", diff --git a/composer.lock b/composer.lock index 2ee826f6..56ab8701 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ec69ea04bcf5c1ebd8bb0280a5bb9565", + "content-hash": "8e387d6d016f33eb7302c47ecb7a12b9", "packages": [ { "name": "amphp/amp", @@ -4997,16 +4997,16 @@ }, { "name": "jbtronics/settings-bundle", - "version": "v3.1.3", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/jbtronics/settings-bundle.git", - "reference": "a99c6e4cde40b829c1643b89da506b9588b11eaf" + "reference": "6a66c099460fd623d0d1ddbf9864b3173d416c3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jbtronics/settings-bundle/zipball/a99c6e4cde40b829c1643b89da506b9588b11eaf", - "reference": "a99c6e4cde40b829c1643b89da506b9588b11eaf", + "url": "https://api.github.com/repos/jbtronics/settings-bundle/zipball/6a66c099460fd623d0d1ddbf9864b3173d416c3b", + "reference": "6a66c099460fd623d0d1ddbf9864b3173d416c3b", "shasum": "" }, "require": { @@ -5067,7 +5067,7 @@ ], "support": { "issues": "https://github.com/jbtronics/settings-bundle/issues", - "source": "https://github.com/jbtronics/settings-bundle/tree/v3.1.3" + "source": "https://github.com/jbtronics/settings-bundle/tree/v3.2.0" }, "funding": [ { @@ -5079,7 +5079,7 @@ "type": "github" } ], - "time": "2026-01-02T23:58:02+00:00" + "time": "2026-02-03T20:13:02+00:00" }, { "name": "jfcherng/php-color-output", @@ -7191,16 +7191,16 @@ }, { "name": "nette/utils", - "version": "v4.1.1", + "version": "v4.1.2", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" + "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", + "url": "https://api.github.com/repos/nette/utils/zipball/f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", "shasum": "" }, "require": { @@ -7213,7 +7213,7 @@ "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", "nette/tester": "^2.5", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/phpstan": "^2.0@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -7274,9 +7274,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.1" + "source": "https://github.com/nette/utils/tree/v4.1.2" }, - "time": "2025-12-22T12:14:32+00:00" + "time": "2026-02-03T17:21:09+00:00" }, { "name": "nikolaposa/version", @@ -18611,28 +18611,28 @@ }, { "name": "phpunit/php-file-iterator", - "version": "5.1.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -18660,15 +18660,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2024-08-27T05:02:59+00:00" + "time": "2026-02-02T13:52:54+00:00" }, { "name": "phpunit/php-invoker", @@ -19029,12 +19041,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "8457f2008fc6396be788162c4e04228028306534" + "reference": "57534122edd70a2b3dbb02b65f2091efc57e4ab7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/8457f2008fc6396be788162c4e04228028306534", - "reference": "8457f2008fc6396be788162c4e04228028306534", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57534122edd70a2b3dbb02b65f2091efc57e4ab7", + "reference": "57534122edd70a2b3dbb02b65f2091efc57e4ab7", "shasum": "" }, "conflict": { @@ -19144,6 +19156,7 @@ "cesnet/simplesamlphp-module-proxystatistics": "<3.1", "chriskacerguis/codeigniter-restserver": "<=2.7.1", "chrome-php/chrome": "<1.14", + "ci4-cms-erp/ci4ms": "<0.28.5", "civicrm/civicrm-core": ">=4.2,<4.2.9|>=4.3,<4.3.3", "ckeditor/ckeditor": "<4.25", "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", @@ -19175,6 +19188,8 @@ "couleurcitron/tarteaucitron-wp": "<0.3", "cpsit/typo3-mailqueue": "<0.4.3|>=0.5,<0.5.1", "craftcms/cms": "<=4.16.16|>=5,<=5.8.20", + "craftcms/commerce": ">=4.0.0.0-RC1-dev,<=4.10|>=5,<=5.5.1", + "craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1", "croogo/croogo": "<=4.0.7", "cuyz/valinor": "<0.12", "czim/file-handling": "<1.5|>=2,<2.3", @@ -19192,7 +19207,7 @@ "derhansen/sf_event_mgt": "<4.3.1|>=5,<5.1.1|>=7,<7.4", "desperado/xml-bundle": "<=0.1.7", "dev-lancer/minecraft-motd-parser": "<=1.0.5", - "devcode-it/openstamanager": "<=2.9.4", + "devcode-it/openstamanager": "<=2.9.8", "devgroup/dotplant": "<2020.09.14-dev", "digimix/wp-svg-upload": "<=1", "directmailteam/direct-mail": "<6.0.3|>=7,<7.0.3|>=8,<9.5.2", @@ -19275,18 +19290,18 @@ "ezsystems/ezplatform-admin-ui-assets": ">=4,<4.2.1|>=5,<5.0.1|>=5.1,<5.1.1|>=5.3.0.0-beta1,<5.3.5", "ezsystems/ezplatform-graphql": ">=1.0.0.0-RC1-dev,<1.0.13|>=2.0.0.0-beta1,<2.3.12", "ezsystems/ezplatform-http-cache": "<2.3.16", - "ezsystems/ezplatform-kernel": "<1.2.5.1-dev|>=1.3,<1.3.35", + "ezsystems/ezplatform-kernel": "<=1.2.5|>=1.3,<1.3.35", "ezsystems/ezplatform-rest": ">=1.2,<=1.2.2|>=1.3,<1.3.8", "ezsystems/ezplatform-richtext": ">=2.3,<2.3.26|>=3.3,<3.3.40", "ezsystems/ezplatform-solr-search-engine": ">=1.7,<1.7.12|>=2,<2.0.2|>=3.3,<3.3.15", "ezsystems/ezplatform-user": ">=1,<1.0.1", - "ezsystems/ezpublish-kernel": "<6.13.8.2-dev|>=7,<7.5.31", + "ezsystems/ezpublish-kernel": "<=6.13.8.1|>=7,<7.5.31", "ezsystems/ezpublish-legacy": "<=2017.12.7.3|>=2018.6,<=2019.03.5.1", "ezsystems/platform-ui-assets-bundle": ">=4.2,<4.2.3", "ezsystems/repository-forms": ">=2.3,<2.3.2.1-dev|>=2.5,<2.5.15", "ezyang/htmlpurifier": "<=4.2", "facade/ignition": "<1.16.15|>=2,<2.4.2|>=2.5,<2.5.2", - "facturascripts/facturascripts": "<=2025.4|==2025.11|==2025.41|==2025.43", + "facturascripts/facturascripts": "<2025.81", "fastly/magento2": "<1.2.26", "feehi/cms": "<=2.1.1", "feehi/feehicms": "<=2.1.1", @@ -19575,7 +19590,7 @@ "open-web-analytics/open-web-analytics": "<1.8.1", "opencart/opencart": ">=0", "openid/php-openid": "<2.3", - "openmage/magento-lts": "<20.16", + "openmage/magento-lts": "<20.16.1", "opensolutions/vimbadmin": "<=3.0.15", "opensource-workshop/connect-cms": "<1.8.7|>=2,<2.4.7", "orchid/platform": ">=8,<14.43", @@ -20037,7 +20052,7 @@ "type": "tidelift" } ], - "time": "2026-01-30T22:06:58+00:00" + "time": "2026-02-03T19:20:38+00:00" }, { "name": "sebastian/cli-parser", @@ -21564,7 +21579,8 @@ "ext-iconv": "*", "ext-intl": "*", "ext-json": "*", - "ext-mbstring": "*" + "ext-mbstring": "*", + "ext-zip": "*" }, "platform-dev": {}, "platform-overrides": { diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 6adea442..846033d6 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -23,3 +23,7 @@ framework: info_provider.cache: adapter: cache.app + + cache.settings: + adapter: cache.app + tags: true diff --git a/config/packages/settings.yaml b/config/packages/settings.yaml index c16d1804..b3d209f6 100644 --- a/config/packages/settings.yaml +++ b/config/packages/settings.yaml @@ -3,6 +3,7 @@ jbtronics_settings: cache: default_cacheable: true + service: 'cache.settings' orm_storage: default_entity_class: App\Entity\SettingsEntry diff --git a/config/permissions.yaml b/config/permissions.yaml index 8c6a145e..0dabf9d3 100644 --- a/config/permissions.yaml +++ b/config/permissions.yaml @@ -297,6 +297,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co show_updates: label: "perm.system.show_available_updates" apiTokenRole: ROLE_API_ADMIN + manage_updates: + label: "perm.system.manage_updates" + alsoSet: ['show_updates', 'server_infos'] + apiTokenRole: ROLE_API_ADMIN attachments: diff --git a/config/reference.php b/config/reference.php index 82bdc45e..a1a077aa 100644 --- a/config/reference.php +++ b/config/reference.php @@ -2387,6 +2387,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * prefetch_all?: bool|Param, // Default: true * }, * cache?: array{ + * metadata_service?: scalar|Param|null, // Default: "cache.system" * service?: scalar|Param|null, // Default: "cache.app.taggable" * default_cacheable?: bool|Param, // Default: false * ttl?: int|Param, // Default: 0 diff --git a/docs/screenshots/update-manager-interface.png b/docs/screenshots/update-manager-interface.png new file mode 100644 index 00000000..62b35ffd Binary files /dev/null and b/docs/screenshots/update-manager-interface.png differ diff --git a/docs/usage/console_commands.md b/docs/usage/console_commands.md index b42bb757..576b3314 100644 --- a/docs/usage/console_commands.md +++ b/docs/usage/console_commands.md @@ -50,6 +50,14 @@ docker exec --user=www-data partdb php bin/console cache:clear * `php bin/console partdb:currencies:update-exchange-rates`: Update the exchange rates of all currencies from the internet +## Update Manager commands + +{: .note } +> The Update Manager is an experimental feature. See the [Update Manager documentation](update_manager.md) for details. + +* `php bin/console partdb:update`: Check for and perform updates to Part-DB. Use `--check` to only check for updates without installing. +* `php bin/console partdb:maintenance-mode`: Enable, disable, or check the status of maintenance mode. Use `--enable`, `--disable`, or `--status`. + ## Installation/Maintenance commands * `php bin/console partdb:backup`: Backup the database and the attachments diff --git a/docs/usage/update_manager.md b/docs/usage/update_manager.md new file mode 100644 index 00000000..43fe2c94 --- /dev/null +++ b/docs/usage/update_manager.md @@ -0,0 +1,170 @@ +--- +title: Update Manager +layout: default +parent: Usage +--- + +# Update Manager (Experimental) + +{: .warning } +> The Update Manager is currently an **experimental feature**. It is disabled by default while user experience data is being gathered. Use with caution and always ensure you have proper backups before updating. + +Part-DB includes an Update Manager that can automatically update Git-based installations to newer versions. The Update Manager provides both a web interface and CLI commands for managing updates, backups, and maintenance mode. + +## Supported Installation Types + +The Update Manager currently supports automatic updates only for **Git clone** installations. Other installation types show manual update instructions: + +| Installation Type | Auto-Update | Instructions | +|-------------------|-------------|--------------| +| Git Clone | Yes | Automatic via CLI or Web UI | +| Docker | No | Pull new image: `docker-compose pull && docker-compose up -d` | +| ZIP Release | No | Download and extract new release manually | + +## Enabling the Update Manager + +By default, web-based updates and backup restore are **disabled** for security reasons. To enable them, add these settings to your `.env.local` file: + +```bash +# Enable web-based updates (default: disabled) +DISABLE_WEB_UPDATES=0 + +# Enable backup restore via web interface (default: disabled) +DISABLE_BACKUP_RESTORE=0 +``` + +{: .note } +> Even with web updates disabled, you can still use the CLI commands to perform updates. + +## CLI Commands + +### Update Command + +Check for updates or perform an update: + +```bash +# Check for available updates +php bin/console partdb:update --check + +# Update to the latest version +php bin/console partdb:update + +# Update to a specific version +php bin/console partdb:update v2.6.0 + +# Update without creating a backup first +php bin/console partdb:update --no-backup + +# Force update without confirmation prompt +php bin/console partdb:update --force +``` + +### Maintenance Mode Command + +Manually enable or disable maintenance mode: + +```bash +# Enable maintenance mode with default message +php bin/console partdb:maintenance-mode --enable + +# Enable with custom message +php bin/console partdb:maintenance-mode --enable "System maintenance until 6 PM" +php bin/console partdb:maintenance-mode --enable --message="Updating to v2.6.0" + +# Disable maintenance mode +php bin/console partdb:maintenance-mode --disable + +# Check current status +php bin/console partdb:maintenance-mode --status +``` + +## Web Interface + +When web updates are enabled, the Update Manager is accessible at **System > Update Manager** (URL: `/system/update-manager`). + +The web interface shows: +- Current version and installation type +- Available updates with release notes +- Precondition validation (Git, Composer, Yarn, permissions) +- Update history and logs +- Backup management + +### Required Permissions + +Users need the following permissions to access the Update Manager: + +| Permission | Description | +|------------|-------------| +| `@system.show_updates` | View update status and available versions | +| `@system.manage_updates` | Perform updates and restore backups | + +## Update Process + +When an update is performed, the following steps are executed: + +1. **Lock** - Acquire exclusive lock to prevent concurrent updates +2. **Maintenance Mode** - Enable maintenance mode to block user access +3. **Rollback Tag** - Create a Git tag for potential rollback +4. **Backup** - Create a full backup (optional but recommended) +5. **Git Fetch** - Fetch latest changes from origin +6. **Git Checkout** - Checkout the target version +7. **Composer Install** - Install/update PHP dependencies +8. **Yarn Install** - Install frontend dependencies +9. **Yarn Build** - Compile frontend assets +10. **Database Migrations** - Run any new migrations +11. **Cache Clear** - Clear the application cache +12. **Cache Warmup** - Rebuild the cache +13. **Maintenance Off** - Disable maintenance mode +14. **Unlock** - Release the update lock + +If any step fails, the system automatically attempts to rollback to the previous version. + +## Backup Management + +The Update Manager automatically creates backups before updates. These backups are stored in `var/backups/` and include: + +- Database dump (SQL file or SQLite database) +- Configuration files (`.env.local`, `parameters.yaml`, `banner.md`) +- Attachment files (`uploads/`, `public/media/`) + +### Restoring from Backup + +{: .warning } +> Backup restore is a destructive operation that will overwrite your current database. Only use this if you need to recover from a failed update. + +If web restore is enabled (`DISABLE_BACKUP_RESTORE=0`), you can restore backups from the web interface. The restore process: + +1. Enables maintenance mode +2. Extracts the backup +3. Restores the database +4. Optionally restores config and attachments +5. Clears and warms up the cache +6. Disables maintenance mode + +## Troubleshooting + +### Precondition Errors + +Before updating, the system validates: + +- **Git available**: Git must be installed and in PATH +- **No local changes**: Uncommitted changes must be committed or stashed +- **Composer available**: Composer must be installed and in PATH +- **Yarn available**: Yarn must be installed and in PATH +- **Write permissions**: `var/`, `vendor/`, and `public/` must be writable +- **Not already locked**: No other update can be in progress + +### Stale Lock + +If an update was interrupted and the lock file remains, it will automatically be removed after 1 hour. You can also manually delete `var/update.lock`. + +### Viewing Update Logs + +Update logs are stored in `var/log/updates/` and can be viewed from the web interface or directly on the server. + +## Security Considerations + +- **Disable web updates in production** unless you specifically need them +- The Update Manager requires shell access to run Git, Composer, and Yarn +- Backup files may contain sensitive data (database, config) - secure the `var/backups/` directory +- Consider running updates during maintenance windows with low user activity diff --git a/makefile b/makefile deleted file mode 100644 index bc4d0bf3..00000000 --- a/makefile +++ /dev/null @@ -1,91 +0,0 @@ -# PartDB Makefile for Test Environment Management - -.PHONY: help deps-install lint format format-check test coverage pre-commit all test-typecheck \ -test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run test-reset \ -section-dev dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset - -# Default target -help: ## Show this help - @awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) - -# Dependencies -deps-install: ## Install PHP dependencies with unlimited memory - @echo "📦 Installing PHP dependencies..." - COMPOSER_MEMORY_LIMIT=-1 composer install - yarn install - @echo "✅ Dependencies installed" - -# Complete test environment setup -test-setup: test-clean test-db-create test-db-migrate test-fixtures ## Complete test setup (clean, create DB, migrate, fixtures) - @echo "✅ Test environment setup complete!" - -# Clean test environment -test-clean: ## Clean test cache and database files - @echo "🧹 Cleaning test environment..." - rm -rf var/cache/test - rm -f var/app_test.db - @echo "✅ Test environment cleaned" - -# Create test database -test-db-create: ## Create test database (if not exists) - @echo "🗄️ Creating test database..." - -php bin/console doctrine:database:create --if-not-exists --env test || echo "⚠️ Database creation failed (expected for SQLite) - continuing..." - -# Run database migrations for test environment -test-db-migrate: ## Run database migrations for test environment - @echo "🔄 Running database migrations..." - COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test - -# Clear test cache -test-cache-clear: ## Clear test cache - @echo "🗑️ Clearing test cache..." - rm -rf var/cache/test - @echo "✅ Test cache cleared" - -# Load test fixtures -test-fixtures: ## Load test fixtures - @echo "📦 Loading test fixtures..." - php bin/console partdb:fixtures:load -n --env test - -# Run PHPUnit tests -test-run: ## Run PHPUnit tests - @echo "🧪 Running tests..." - php bin/phpunit - -# Quick test reset (clean + migrate + fixtures, skip DB creation) -test-reset: test-cache-clear test-db-migrate test-fixtures - @echo "✅ Test environment reset complete!" - -test-typecheck: ## Run static analysis (PHPStan) - @echo "🧪 Running type checks..." - COMPOSER_MEMORY_LIMIT=-1 composer phpstan - -# Development helpers -dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup ## Complete development setup (clean, create DB, migrate, warmup) - @echo "✅ Development environment setup complete!" - -dev-clean: ## Clean development cache and database files - @echo "🧹 Cleaning development environment..." - rm -rf var/cache/dev - rm -f var/app_dev.db - @echo "✅ Development environment cleaned" - -dev-db-create: ## Create development database (if not exists) - @echo "🗄️ Creating development database..." - -php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..." - -dev-db-migrate: ## Run database migrations for development environment - @echo "🔄 Running database migrations..." - COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev - -dev-cache-clear: ## Clear development cache - @echo "🗑️ Clearing development cache..." - rm -rf var/cache/dev - @echo "✅ Development cache cleared" - -dev-warmup: ## Warm up development cache - @echo "🔥 Warming up development cache..." - COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n - -dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate) - @echo "✅ Development environment reset complete!" \ No newline at end of file diff --git a/phpstan.dist.neon b/phpstan.dist.neon index fc7b3524..eb629314 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -6,6 +6,9 @@ parameters: - src # - tests + banned_code: + non_ignorable: false # Allow to ignore some banned code + excludePaths: - src/DataTables/Adapter/* - src/Configuration/* @@ -61,3 +64,6 @@ parameters: # Ignore error of unused WithPermPresetsTrait, as it is used in the migrations which are not analyzed by Phpstan - '#Trait App\\Migration\\WithPermPresetsTrait is used zero times and is not analysed#' + - + message: '#Should not use function "shell_exec"#' + path: src/Services/System/UpdateExecutor.php diff --git a/src/Command/MaintenanceModeCommand.php b/src/Command/MaintenanceModeCommand.php new file mode 100644 index 00000000..47b1eaef --- /dev/null +++ b/src/Command/MaintenanceModeCommand.php @@ -0,0 +1,141 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Command; + +use App\Services\System\UpdateExecutor; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +#[AsCommand('partdb:maintenance-mode', 'Enable/disable maintenance mode and set a message')] +class MaintenanceModeCommand extends Command +{ + public function __construct( + private readonly UpdateExecutor $updateExecutor + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setDefinition([ + new InputOption('enable', null, InputOption::VALUE_NONE, 'Enable maintenance mode'), + new InputOption('disable', null, InputOption::VALUE_NONE, 'Disable maintenance mode'), + new InputOption('status', null, InputOption::VALUE_NONE, 'Show current maintenance mode status'), + new InputOption('message', null, InputOption::VALUE_REQUIRED, 'Optional maintenance message (explicit option)'), + new InputArgument('message_arg', InputArgument::OPTIONAL, 'Optional maintenance message as a positional argument (preferred when writing message directly)') + ]); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $enable = (bool)$input->getOption('enable'); + $disable = (bool)$input->getOption('disable'); + $status = (bool)$input->getOption('status'); + + // Accept message either via --message option or as positional argument + $optionMessage = $input->getOption('message'); + $argumentMessage = $input->getArgument('message_arg'); + + // Prefer explicit --message option, otherwise use positional argument if provided + $message = null; + if (is_string($optionMessage) && $optionMessage !== '') { + $message = $optionMessage; + } elseif (is_string($argumentMessage) && $argumentMessage !== '') { + $message = $argumentMessage; + } + + // If no action provided, show help + if (!$enable && !$disable && !$status) { + $io->text('Maintenance mode command. See usage below:'); + $this->printHelp($io); + return Command::SUCCESS; + } + + if ($enable && $disable) { + $io->error('Conflicting options: specify either --enable or --disable, not both.'); + return Command::FAILURE; + } + + try { + if ($status) { + if ($this->updateExecutor->isMaintenanceMode()) { + $info = $this->updateExecutor->getMaintenanceInfo(); + $reason = $info['reason'] ?? 'Unknown reason'; + $enabledAt = $info['enabled_at'] ?? 'Unknown time'; + + $io->success(sprintf('Maintenance mode is ENABLED (since %s).', $enabledAt)); + $io->text(sprintf('Reason: %s', $reason)); + } else { + $io->success('Maintenance mode is DISABLED.'); + } + + // If only status requested, exit + if (!$enable && !$disable) { + return Command::SUCCESS; + } + } + + if ($enable) { + // Use provided message or fallback to a default English message + $reason = is_string($message) + ? $message + : 'The system is temporarily unavailable due to maintenance.'; + + $this->updateExecutor->enableMaintenanceMode($reason); + + $io->success(sprintf('Maintenance mode enabled. Reason: %s', $reason)); + } + + if ($disable) { + $this->updateExecutor->disableMaintenanceMode(); + $io->success('Maintenance mode disabled.'); + } + + return Command::SUCCESS; + } catch (\Throwable $e) { + $io->error(sprintf('Unexpected error: %s', $e->getMessage())); + return Command::FAILURE; + } + } + + private function printHelp(SymfonyStyle $io): void + { + $io->writeln(''); + $io->writeln('Usage:'); + $io->writeln(' php bin/console partdb:maintenance_mode --enable [--message="Maintenance message"]'); + $io->writeln(' php bin/console partdb:maintenance_mode --enable "Maintenance message"'); + $io->writeln(' php bin/console partdb:maintenance_mode --disable'); + $io->writeln(' php bin/console partdb:maintenance_mode --status'); + $io->writeln(''); + } + +} diff --git a/src/Command/UpdateCommand.php b/src/Command/UpdateCommand.php new file mode 100644 index 00000000..ca6c8399 --- /dev/null +++ b/src/Command/UpdateCommand.php @@ -0,0 +1,445 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Command; + +use App\Services\System\UpdateChecker; +use App\Services\System\UpdateExecutor; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +#[AsCommand(name: 'partdb:update', description: 'Check for and install Part-DB updates', aliases: ['app:update'])] +class UpdateCommand extends Command +{ + public function __construct(private readonly UpdateChecker $updateChecker, + private readonly UpdateExecutor $updateExecutor) + { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setHelp(<<<'HELP' +The %command.name% command checks for Part-DB updates and can install them. + +Check for updates: + php %command.full_name% --check + +List available versions: + php %command.full_name% --list + +Update to the latest version: + php %command.full_name% + +Update to a specific version: + php %command.full_name% v2.6.0 + +Update without creating a backup (faster but riskier): + php %command.full_name% --no-backup + +Non-interactive update for scripts: + php %command.full_name% --force + +View update logs: + php %command.full_name% --logs +HELP + ) + ->addArgument( + 'version', + InputArgument::OPTIONAL, + 'Target version to update to (e.g., v2.6.0). If not specified, updates to the latest stable version.' + ) + ->addOption( + 'check', + 'c', + InputOption::VALUE_NONE, + 'Only check for updates without installing' + ) + ->addOption( + 'list', + 'l', + InputOption::VALUE_NONE, + 'List all available versions' + ) + ->addOption( + 'no-backup', + null, + InputOption::VALUE_NONE, + 'Skip creating a backup before updating (not recommended)' + ) + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Skip confirmation prompts' + ) + ->addOption( + 'include-prerelease', + null, + InputOption::VALUE_NONE, + 'Include pre-release versions' + ) + ->addOption( + 'logs', + null, + InputOption::VALUE_NONE, + 'Show recent update logs' + ) + ->addOption( + 'refresh', + 'r', + InputOption::VALUE_NONE, + 'Force refresh of cached version information' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + // Handle --logs option + if ($input->getOption('logs')) { + return $this->showLogs($io); + } + + // Handle --refresh option + if ($input->getOption('refresh')) { + $io->text('Refreshing version information...'); + $this->updateChecker->refreshVersionInfo(); + $io->success('Version cache cleared.'); + } + + // Handle --list option + if ($input->getOption('list')) { + return $this->listVersions($io, $input->getOption('include-prerelease')); + } + + // Get update status + $status = $this->updateChecker->getUpdateStatus(); + + // Display current status + $io->title('Part-DB Update Manager'); + + $this->displayStatus($io, $status); + + // Handle --check option + if ($input->getOption('check')) { + return $this->checkOnly($io, $status); + } + + // Validate we can update + $validationResult = $this->validateUpdate($io, $status); + if ($validationResult !== null) { + return $validationResult; + } + + // Determine target version + $targetVersion = $input->getArgument('version'); + $includePrerelease = $input->getOption('include-prerelease'); + + if (!$targetVersion) { + $latest = $this->updateChecker->getLatestVersion($includePrerelease); + if (!$latest) { + $io->error('Could not determine the latest version. Please specify a version manually.'); + return Command::FAILURE; + } + $targetVersion = $latest['tag']; + } + + // Validate target version + if (!$this->updateChecker->isNewerVersionThanCurrent($targetVersion)) { + $io->warning(sprintf( + 'Version %s is not newer than the current version %s.', + $targetVersion, + $status['current_version'] + )); + + if (!$input->getOption('force')) { + if (!$io->confirm('Do you want to proceed anyway?', false)) { + $io->info('Update cancelled.'); + return Command::SUCCESS; + } + } + } + + // Confirm update + if (!$input->getOption('force')) { + $io->section('Update Plan'); + + $io->listing([ + sprintf('Target version: %s', $targetVersion), + $input->getOption('no-backup') + ? 'Backup will be SKIPPED' + : 'A full backup will be created before updating', + 'Maintenance mode will be enabled during update', + 'Database migrations will be run automatically', + 'Cache will be cleared and rebuilt', + ]); + + $io->warning('The update process may take several minutes. Do not interrupt it.'); + + if (!$io->confirm('Do you want to proceed with the update?', false)) { + $io->info('Update cancelled.'); + return Command::SUCCESS; + } + } + + // Execute update + return $this->executeUpdate($io, $targetVersion, !$input->getOption('no-backup')); + } + + private function displayStatus(SymfonyStyle $io, array $status): void + { + $io->definitionList( + ['Current Version' => sprintf('%s', $status['current_version'])], + ['Latest Version' => $status['latest_version'] + ? sprintf('%s', $status['latest_version']) + : 'Unknown'], + ['Installation Type' => $status['installation']['type_name']], + ['Git Branch' => $status['git']['branch'] ?? 'N/A'], + ['Git Commit' => $status['git']['commit'] ?? 'N/A'], + ['Local Changes' => $status['git']['has_local_changes'] + ? 'Yes (update blocked)' + : 'No'], + ['Commits Behind' => $status['git']['commits_behind'] > 0 + ? sprintf('%d', $status['git']['commits_behind']) + : '0'], + ['Update Available' => $status['update_available'] + ? 'Yes' + : 'No'], + ['Can Auto-Update' => $status['can_auto_update'] + ? 'Yes' + : 'No'], + ); + + if (!empty($status['update_blockers'])) { + $io->warning('Update blockers: ' . implode(', ', $status['update_blockers'])); + } + } + + private function checkOnly(SymfonyStyle $io, array $status): int + { + if (!$status['check_enabled']) { + $io->warning('Update checking is disabled in privacy settings.'); + return Command::SUCCESS; + } + + if ($status['update_available']) { + $io->success(sprintf( + 'A new version is available: %s (current: %s)', + $status['latest_version'], + $status['current_version'] + )); + + if ($status['release_url']) { + $io->text(sprintf('Release notes: %s', $status['release_url'], $status['release_url'])); + } + + if ($status['can_auto_update']) { + $io->text(''); + $io->text('Run php bin/console partdb:update to update.'); + } else { + $io->text(''); + $io->text($status['installation']['update_instructions']); + } + + return Command::SUCCESS; + } + + $io->success('You are running the latest version.'); + return Command::SUCCESS; + } + + private function validateUpdate(SymfonyStyle $io, array $status): ?int + { + // Check if update checking is enabled + if (!$status['check_enabled']) { + $io->error('Update checking is disabled in privacy settings. Enable it to use automatic updates.'); + return Command::FAILURE; + } + + // Check installation type + if (!$status['can_auto_update']) { + $io->error('Automatic updates are not supported for this installation type.'); + $io->text($status['installation']['update_instructions']); + return Command::FAILURE; + } + + // Validate preconditions + $validation = $this->updateExecutor->validateUpdatePreconditions(); + if (!$validation['valid']) { + $io->error('Cannot proceed with update:'); + $io->listing($validation['errors']); + return Command::FAILURE; + } + + return null; + } + + private function executeUpdate(SymfonyStyle $io, string $targetVersion, bool $createBackup): int + { + $io->section('Executing Update'); + $io->text(sprintf('Updating to version: %s', $targetVersion)); + $io->text(''); + + $progressCallback = function (array $step) use ($io): void { + $icon = $step['success'] ? '✓' : '✗'; + $duration = $step['duration'] ? sprintf(' (%.1fs)', $step['duration']) : ''; + $io->text(sprintf(' %s %s: %s%s', $icon, $step['step'], $step['message'], $duration)); + }; + + // Use executeUpdateWithProgress to update the progress file for web UI + $result = $this->updateExecutor->executeUpdateWithProgress($targetVersion, $createBackup, $progressCallback); + + $io->text(''); + + if ($result['success']) { + $io->success(sprintf( + 'Successfully updated to %s in %.1f seconds!', + $targetVersion, + $result['duration'] + )); + + $io->text([ + sprintf('Rollback tag: %s', $result['rollback_tag']), + sprintf('Log file: %s', $result['log_file']), + ]); + + $io->note('If you encounter any issues, you can rollback using: git checkout ' . $result['rollback_tag']); + + return Command::SUCCESS; + } + + $io->error('Update failed: ' . $result['error']); + + if ($result['rollback_tag']) { + $io->warning(sprintf('System was rolled back to: %s', $result['rollback_tag'])); + } + + if ($result['log_file']) { + $io->text(sprintf('See log file for details: %s', $result['log_file'])); + } + + return Command::FAILURE; + } + + private function listVersions(SymfonyStyle $io, bool $includePrerelease): int + { + $releases = $this->updateChecker->getAvailableReleases(15); + $currentVersion = $this->updateChecker->getCurrentVersionString(); + + if (empty($releases)) { + $io->warning('Could not fetch available versions. Check your internet connection.'); + return Command::FAILURE; + } + + $io->title('Available Part-DB Versions'); + + $table = new Table($io); + $table->setHeaders(['Tag', 'Version', 'Released', 'Status']); + + foreach ($releases as $release) { + if (!$includePrerelease && $release['prerelease']) { + continue; + } + + $version = $release['version']; + $status = []; + + if (version_compare($version, $currentVersion, '=')) { + $status[] = 'current'; + } elseif (version_compare($version, $currentVersion, '>')) { + $status[] = 'newer'; + } + + if ($release['prerelease']) { + $status[] = 'pre-release'; + } + + $table->addRow([ + $release['tag'], + $version, + (new \DateTime($release['published_at']))->format('Y-m-d'), + implode(' ', $status) ?: '-', + ]); + } + + $table->render(); + + $io->text(''); + $io->text('Use php bin/console partdb:update [tag] to update to a specific version.'); + + return Command::SUCCESS; + } + + private function showLogs(SymfonyStyle $io): int + { + $logs = $this->updateExecutor->getUpdateLogs(); + + if (empty($logs)) { + $io->info('No update logs found.'); + return Command::SUCCESS; + } + + $io->title('Recent Update Logs'); + + $table = new Table($io); + $table->setHeaders(['Date', 'File', 'Size']); + + foreach (array_slice($logs, 0, 10) as $log) { + $table->addRow([ + date('Y-m-d H:i:s', $log['date']), + $log['file'], + $this->formatBytes($log['size']), + ]); + } + + $table->render(); + + $io->text(''); + $io->text('Log files are stored in: var/log/updates/'); + + return Command::SUCCESS; + } + + private function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $unitIndex = 0; + + while ($bytes >= 1024 && $unitIndex < count($units) - 1) { + $bytes /= 1024; + $unitIndex++; + } + + return sprintf('%.1f %s', $bytes, $units[$unitIndex]); + } +} diff --git a/src/Command/VersionCommand.php b/src/Command/VersionCommand.php index d2ce75e1..d09def8f 100644 --- a/src/Command/VersionCommand.php +++ b/src/Command/VersionCommand.php @@ -22,9 +22,9 @@ declare(strict_types=1); */ namespace App\Command; -use Symfony\Component\Console\Attribute\AsCommand; -use App\Services\Misc\GitVersionInfo; +use App\Services\System\GitVersionInfoProvider; use Shivas\VersioningBundle\Service\VersionManagerInterface; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -33,7 +33,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; #[AsCommand('partdb:version|app:version', 'Shows the currently installed version of Part-DB.')] class VersionCommand extends Command { - public function __construct(protected VersionManagerInterface $versionManager, protected GitVersionInfo $gitVersionInfo) + public function __construct(protected VersionManagerInterface $versionManager, protected GitVersionInfoProvider $gitVersionInfo) { parent::__construct(); } @@ -48,9 +48,9 @@ class VersionCommand extends Command $message = 'Part-DB version: '. $this->versionManager->getVersion()->toString(); - if ($this->gitVersionInfo->getGitBranchName() !== null) { - $message .= ' Git branch: '. $this->gitVersionInfo->getGitBranchName(); - $message .= ', Git commit: '. $this->gitVersionInfo->getGitCommitHash(); + if ($this->gitVersionInfo->getBranchName() !== null) { + $message .= ' Git branch: '. $this->gitVersionInfo->getBranchName(); + $message .= ', Git commit: '. $this->gitVersionInfo->getCommitHash(); } $io->success($message); diff --git a/src/Controller/HomepageController.php b/src/Controller/HomepageController.php index 076e790b..6f863a3c 100644 --- a/src/Controller/HomepageController.php +++ b/src/Controller/HomepageController.php @@ -24,9 +24,9 @@ namespace App\Controller; use App\DataTables\LogDataTable; use App\Entity\Parts\Part; -use App\Services\Misc\GitVersionInfo; use App\Services\System\BannerHelper; -use App\Services\System\UpdateAvailableManager; +use App\Services\System\GitVersionInfoProvider; +use App\Services\System\UpdateAvailableFacade; use Doctrine\ORM\EntityManagerInterface; use Omines\DataTablesBundle\DataTableFactory; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -43,8 +43,8 @@ class HomepageController extends AbstractController #[Route(path: '/', name: 'homepage')] - public function homepage(Request $request, GitVersionInfo $versionInfo, EntityManagerInterface $entityManager, - UpdateAvailableManager $updateAvailableManager): Response + public function homepage(Request $request, GitVersionInfoProvider $versionInfo, EntityManagerInterface $entityManager, + UpdateAvailableFacade $updateAvailableManager): Response { $this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS'); @@ -77,8 +77,8 @@ class HomepageController extends AbstractController return $this->render('homepage.html.twig', [ 'banner' => $this->bannerHelper->getBanner(), - 'git_branch' => $versionInfo->getGitBranchName(), - 'git_commit' => $versionInfo->getGitCommitHash(), + 'git_branch' => $versionInfo->getBranchName(), + 'git_commit' => $versionInfo->getCommitHash(), 'show_first_steps' => $show_first_steps, 'datatable' => $table, 'new_version_available' => $updateAvailableManager->isUpdateAvailable(), diff --git a/src/Controller/ToolsController.php b/src/Controller/ToolsController.php index d78aff62..76dffb4d 100644 --- a/src/Controller/ToolsController.php +++ b/src/Controller/ToolsController.php @@ -27,8 +27,8 @@ use App\Services\Attachments\AttachmentURLGenerator; use App\Services\Attachments\BuiltinAttachmentsFinder; use App\Services\Doctrine\DBInfoHelper; use App\Services\Doctrine\NatsortDebugHelper; -use App\Services\Misc\GitVersionInfo; -use App\Services\System\UpdateAvailableManager; +use App\Services\System\GitVersionInfoProvider; +use App\Services\System\UpdateAvailableFacade; use App\Settings\AppSettings; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; @@ -47,16 +47,16 @@ class ToolsController extends AbstractController } #[Route(path: '/server_infos', name: 'tools_server_infos')] - public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper, - AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager, + public function systemInfos(GitVersionInfoProvider $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper, + AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableFacade $updateAvailableManager, AppSettings $settings): Response { $this->denyAccessUnlessGranted('@system.server_infos'); return $this->render('tools/server_infos/server_infos.html.twig', [ //Part-DB section - 'git_branch' => $versionInfo->getGitBranchName(), - 'git_commit' => $versionInfo->getGitCommitHash(), + 'git_branch' => $versionInfo->getBranchName(), + 'git_commit' => $versionInfo->getCommitHash(), 'default_locale' => $settings->system->localization->locale, 'default_timezone' => $settings->system->localization->timezone, 'default_currency' => $settings->system->localization->baseCurrency, diff --git a/src/Controller/UpdateManagerController.php b/src/Controller/UpdateManagerController.php new file mode 100644 index 00000000..474c86fc --- /dev/null +++ b/src/Controller/UpdateManagerController.php @@ -0,0 +1,371 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Controller; + +use App\Services\System\BackupManager; +use App\Services\System\UpdateChecker; +use App\Services\System\UpdateExecutor; +use Shivas\VersioningBundle\Service\VersionManagerInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Attribute\Route; + +/** + * Controller for the Update Manager web interface. + * + * This provides a read-only view of update status and instructions. + * Actual updates should be performed via the CLI command for safety. + */ +#[Route('/system/update-manager')] +class UpdateManagerController extends AbstractController +{ + public function __construct( + private readonly UpdateChecker $updateChecker, + private readonly UpdateExecutor $updateExecutor, + private readonly VersionManagerInterface $versionManager, + private readonly BackupManager $backupManager, + #[Autowire(env: 'bool:DISABLE_WEB_UPDATES')] + private readonly bool $webUpdatesDisabled = false, + #[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')] + private readonly bool $backupRestoreDisabled = false, + ) { + } + + /** + * Check if web updates are disabled and throw exception if so. + */ + private function denyIfWebUpdatesDisabled(): void + { + if ($this->webUpdatesDisabled) { + throw new AccessDeniedHttpException('Web-based updates are disabled by server configuration. Please use the CLI command instead.'); + } + } + + /** + * Check if backup restore is disabled and throw exception if so. + */ + private function denyIfBackupRestoreDisabled(): void + { + if ($this->backupRestoreDisabled) { + throw new AccessDeniedHttpException('Backup restore is disabled by server configuration.'); + } + } + + /** + * Main update manager page. + */ + #[Route('', name: 'admin_update_manager', methods: ['GET'])] + public function index(): Response + { + $this->denyAccessUnlessGranted('@system.show_updates'); + + $status = $this->updateChecker->getUpdateStatus(); + $availableUpdates = $this->updateChecker->getAvailableUpdates(); + $validation = $this->updateExecutor->validateUpdatePreconditions(); + + return $this->render('admin/update_manager/index.html.twig', [ + 'status' => $status, + 'available_updates' => $availableUpdates, + 'all_releases' => $this->updateChecker->getAvailableReleases(10), + 'validation' => $validation, + 'is_locked' => $this->updateExecutor->isLocked(), + 'lock_info' => $this->updateExecutor->getLockInfo(), + 'is_maintenance' => $this->updateExecutor->isMaintenanceMode(), + 'maintenance_info' => $this->updateExecutor->getMaintenanceInfo(), + 'update_logs' => $this->updateExecutor->getUpdateLogs(), + 'backups' => $this->backupManager->getBackups(), + 'web_updates_disabled' => $this->webUpdatesDisabled, + 'backup_restore_disabled' => $this->backupRestoreDisabled, + ]); + } + + /** + * AJAX endpoint to check update status. + */ + #[Route('/status', name: 'admin_update_manager_status', methods: ['GET'])] + public function status(): JsonResponse + { + $this->denyAccessUnlessGranted('@system.show_updates'); + + return $this->json([ + 'status' => $this->updateChecker->getUpdateStatus(), + 'is_locked' => $this->updateExecutor->isLocked(), + 'is_maintenance' => $this->updateExecutor->isMaintenanceMode(), + 'lock_info' => $this->updateExecutor->getLockInfo(), + ]); + } + + /** + * AJAX endpoint to refresh version information. + */ + #[Route('/refresh', name: 'admin_update_manager_refresh', methods: ['POST'])] + public function refresh(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('@system.show_updates'); + + // Validate CSRF token + if (!$this->isCsrfTokenValid('update_manager_refresh', $request->request->get('_token'))) { + return $this->json(['error' => 'Invalid CSRF token'], Response::HTTP_FORBIDDEN); + } + + $this->updateChecker->refreshVersionInfo(); + + return $this->json([ + 'success' => true, + 'status' => $this->updateChecker->getUpdateStatus(), + ]); + } + + /** + * View release notes for a specific version. + */ + #[Route('/release/{tag}', name: 'admin_update_manager_release', methods: ['GET'])] + public function releaseNotes(string $tag): Response + { + $this->denyAccessUnlessGranted('@system.show_updates'); + + $releases = $this->updateChecker->getAvailableReleases(20); + $release = null; + + foreach ($releases as $r) { + if ($r['tag'] === $tag) { + $release = $r; + break; + } + } + + if (!$release) { + throw $this->createNotFoundException('Release not found'); + } + + return $this->render('admin/update_manager/release_notes.html.twig', [ + 'release' => $release, + 'current_version' => $this->updateChecker->getCurrentVersionString(), + ]); + } + + /** + * View an update log file. + */ + #[Route('/log/{filename}', name: 'admin_update_manager_log', methods: ['GET'])] + public function viewLog(string $filename): Response + { + $this->denyAccessUnlessGranted('@system.manage_updates'); + + // Security: Only allow viewing files from the update logs directory + $logs = $this->updateExecutor->getUpdateLogs(); + $logPath = null; + + foreach ($logs as $log) { + if ($log['file'] === $filename) { + $logPath = $log['path']; + break; + } + } + + if (!$logPath || !file_exists($logPath)) { + throw $this->createNotFoundException('Log file not found'); + } + + $content = file_get_contents($logPath); + + return $this->render('admin/update_manager/log_viewer.html.twig', [ + 'filename' => $filename, + 'content' => $content, + ]); + } + + /** + * Start an update process. + */ + #[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])] + public function startUpdate(Request $request): Response + { + $this->denyAccessUnlessGranted('@system.manage_updates'); + $this->denyIfWebUpdatesDisabled(); + + // Validate CSRF token + if (!$this->isCsrfTokenValid('update_manager_start', $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid CSRF token'); + return $this->redirectToRoute('admin_update_manager'); + } + + // Check if update is already running + if ($this->updateExecutor->isLocked() || $this->updateExecutor->isUpdateRunning()) { + $this->addFlash('error', 'An update is already in progress.'); + return $this->redirectToRoute('admin_update_manager'); + } + + $targetVersion = $request->request->get('version'); + $createBackup = $request->request->getBoolean('backup', true); + + if (!$targetVersion) { + // Get latest version if not specified + $latest = $this->updateChecker->getLatestVersion(); + if (!$latest) { + $this->addFlash('error', 'Could not determine target version.'); + return $this->redirectToRoute('admin_update_manager'); + } + $targetVersion = $latest['tag']; + } + + // Validate preconditions + $validation = $this->updateExecutor->validateUpdatePreconditions(); + if (!$validation['valid']) { + $this->addFlash('error', implode(' ', $validation['errors'])); + return $this->redirectToRoute('admin_update_manager'); + } + + // Start the background update + $pid = $this->updateExecutor->startBackgroundUpdate($targetVersion, $createBackup); + + if (!$pid) { + $this->addFlash('error', 'Failed to start update process.'); + return $this->redirectToRoute('admin_update_manager'); + } + + // Redirect to progress page + return $this->redirectToRoute('admin_update_manager_progress'); + } + + /** + * Update progress page. + */ + #[Route('/progress', name: 'admin_update_manager_progress', methods: ['GET'])] + public function progress(): Response + { + $this->denyAccessUnlessGranted('@system.manage_updates'); + + $progress = $this->updateExecutor->getProgress(); + $currentVersion = $this->versionManager->getVersion()->toString(); + + // Determine if this is a downgrade + $isDowngrade = false; + if ($progress && isset($progress['target_version'])) { + $targetVersion = ltrim($progress['target_version'], 'v'); + $isDowngrade = version_compare($targetVersion, $currentVersion, '<'); + } + + return $this->render('admin/update_manager/progress.html.twig', [ + 'progress' => $progress, + 'is_locked' => $this->updateExecutor->isLocked(), + 'is_maintenance' => $this->updateExecutor->isMaintenanceMode(), + 'is_downgrade' => $isDowngrade, + 'current_version' => $currentVersion, + ]); + } + + /** + * AJAX endpoint to get update progress. + */ + #[Route('/progress/status', name: 'admin_update_manager_progress_status', methods: ['GET'])] + public function progressStatus(): JsonResponse + { + $this->denyAccessUnlessGranted('@system.show_updates'); + + $progress = $this->updateExecutor->getProgress(); + + return $this->json([ + 'progress' => $progress, + 'is_locked' => $this->updateExecutor->isLocked(), + 'is_maintenance' => $this->updateExecutor->isMaintenanceMode(), + ]); + } + + /** + * Get backup details for restore confirmation. + */ + #[Route('/backup/{filename}', name: 'admin_update_manager_backup_details', methods: ['GET'])] + public function backupDetails(string $filename): JsonResponse + { + $this->denyAccessUnlessGranted('@system.manage_updates'); + + $details = $this->backupManager->getBackupDetails($filename); + + if (!$details) { + return $this->json(['error' => 'Backup not found'], 404); + } + + return $this->json($details); + } + + /** + * Restore from a backup. + */ + #[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])] + public function restore(Request $request): Response + { + $this->denyAccessUnlessGranted('@system.manage_updates'); + $this->denyIfBackupRestoreDisabled(); + + // Validate CSRF token + if (!$this->isCsrfTokenValid('update_manager_restore', $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid CSRF token.'); + return $this->redirectToRoute('admin_update_manager'); + } + + // Check if already locked + if ($this->updateExecutor->isLocked()) { + $this->addFlash('error', 'An update or restore is already in progress.'); + return $this->redirectToRoute('admin_update_manager'); + } + + $filename = $request->request->get('filename'); + $restoreDatabase = $request->request->getBoolean('restore_database', true); + $restoreConfig = $request->request->getBoolean('restore_config', false); + $restoreAttachments = $request->request->getBoolean('restore_attachments', false); + + if (!$filename) { + $this->addFlash('error', 'No backup file specified.'); + return $this->redirectToRoute('admin_update_manager'); + } + + // Verify the backup exists + $backupDetails = $this->backupManager->getBackupDetails($filename); + if (!$backupDetails) { + $this->addFlash('error', 'Backup file not found.'); + return $this->redirectToRoute('admin_update_manager'); + } + + // Execute restore (this is a synchronous operation for now - could be made async later) + $result = $this->updateExecutor->restoreBackup( + $filename, + $restoreDatabase, + $restoreConfig, + $restoreAttachments + ); + + if ($result['success']) { + $this->addFlash('success', 'Backup restored successfully.'); + } else { + $this->addFlash('error', 'Restore failed: ' . ($result['error'] ?? 'Unknown error')); + } + + return $this->redirectToRoute('admin_update_manager'); + } +} diff --git a/src/EventSubscriber/MaintenanceModeSubscriber.php b/src/EventSubscriber/MaintenanceModeSubscriber.php new file mode 100644 index 00000000..654ba9f2 --- /dev/null +++ b/src/EventSubscriber/MaintenanceModeSubscriber.php @@ -0,0 +1,230 @@ +. + */ + +declare(strict_types=1); + + +namespace App\EventSubscriber; + +use App\Services\System\UpdateExecutor; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\FilterResponseEvent; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Twig\Environment; + +/** + * Blocks all web requests when maintenance mode is enabled during updates. + */ +readonly class MaintenanceModeSubscriber implements EventSubscriberInterface +{ + public function __construct(private UpdateExecutor $updateExecutor) + { + + } + + public static function getSubscribedEvents(): array + { + return [ + // High priority to run before other listeners + KernelEvents::REQUEST => ['onKernelRequest', 512], //High priority to run before other listeners + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + // Only handle main requests + if (!$event->isMainRequest()) { + return; + } + + // Skip if not in maintenance mode + if (!$this->updateExecutor->isMaintenanceMode()) { + return; + } + + //Allow to view the progress page + if (preg_match('#^/\w{2}/system/update-manager/progress#', $event->getRequest()->getPathInfo())) { + return; + } + + // Allow CLI requests + if (PHP_SAPI === 'cli') { + return; + } + + // Get maintenance info + $maintenanceInfo = $this->updateExecutor->getMaintenanceInfo(); + + // Calculate how long the update has been running + $duration = null; + if ($maintenanceInfo && isset($maintenanceInfo['enabled_at'])) { + try { + $startedAt = new \DateTime($maintenanceInfo['enabled_at']); + $now = new \DateTime(); + $duration = $now->getTimestamp() - $startedAt->getTimestamp(); + } catch (\Exception) { + // Ignore date parsing errors + } + } + + $content = $this->getSimpleMaintenanceHtml($maintenanceInfo, $duration); + + $response = new Response($content, Response::HTTP_SERVICE_UNAVAILABLE); + $response->headers->set('Retry-After', '30'); + $response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate'); + + $event->setResponse($response); + } + + /** + * Generate a simple maintenance page HTML without Twig. + */ + private function getSimpleMaintenanceHtml(?array $maintenanceInfo, ?int $duration): string + { + $reason = htmlspecialchars($maintenanceInfo['reason'] ?? 'Update in progress'); + $durationText = $duration !== null ? sprintf('%d seconds', $duration) : 'a moment'; + + $startDateStr = $maintenanceInfo['enabled_at'] ?? 'unknown time'; + + return << + + + + + + Part-DB - Maintenance + + + +
+
+ ⚙️ +
+

Part-DB is under maintenance

+

We're making things better. This should only take a moment.

+ +
+ {$reason} +
+ +
+
+
+ +

+ Maintenance mode active since {$startDateStr}
+
+ Started {$durationText} ago
+ This page will automatically refresh every 15 seconds. +

+
+ + +HTML; + } +} diff --git a/src/Services/Misc/GitVersionInfo.php b/src/Services/Misc/GitVersionInfo.php deleted file mode 100644 index 3c079f4f..00000000 --- a/src/Services/Misc/GitVersionInfo.php +++ /dev/null @@ -1,83 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Services\Misc; - -use Symfony\Component\HttpKernel\KernelInterface; - -class GitVersionInfo -{ - protected string $project_dir; - - public function __construct(KernelInterface $kernel) - { - $this->project_dir = $kernel->getProjectDir(); - } - - /** - * Get the Git branch name of the installed system. - * - * @return string|null The current git branch name. Null, if this is no Git installation - */ - public function getGitBranchName(): ?string - { - if (is_file($this->project_dir.'/.git/HEAD')) { - $git = file($this->project_dir.'/.git/HEAD'); - $head = explode('/', $git[0], 3); - - if (!isset($head[2])) { - return null; - } - - return trim($head[2]); - } - - return null; // this is not a Git installation - } - - /** - * Get hash of the last git commit (on remote "origin"!). - * - * If this method does not work, try to make a "git pull" first! - * - * @param int $length if this is smaller than 40, only the first $length characters will be returned - * - * @return string|null The hash of the last commit, null If this is no Git installation - */ - public function getGitCommitHash(int $length = 7): ?string - { - $filename = $this->project_dir.'/.git/refs/remotes/origin/'.$this->getGitBranchName(); - if (is_file($filename)) { - $head = file($filename); - - if (!isset($head[0])) { - return null; - } - - $hash = $head[0]; - - return substr($hash, 0, $length); - } - - return null; // this is not a Git installation - } -} diff --git a/src/Services/System/BackupManager.php b/src/Services/System/BackupManager.php new file mode 100644 index 00000000..9bdc7f71 --- /dev/null +++ b/src/Services/System/BackupManager.php @@ -0,0 +1,453 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\System; + +use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; +use Shivas\VersioningBundle\Service\VersionManagerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Process\Process; + +/** + * Manages Part-DB backups: creation, restoration, and listing. + * + * This service handles all backup-related operations and can be used + * by the Update Manager, CLI commands, or other services. + */ +readonly class BackupManager +{ + private const BACKUP_DIR = 'var/backups'; + + public function __construct( + #[Autowire(param: 'kernel.project_dir')] + private string $projectDir, + private LoggerInterface $logger, + private Filesystem $filesystem, + private VersionManagerInterface $versionManager, + private EntityManagerInterface $entityManager, + private CommandRunHelper $commandRunHelper, + ) { + } + + /** + * Get the backup directory path. + */ + public function getBackupDir(): string + { + return $this->projectDir . '/' . self::BACKUP_DIR; + } + + /** + * Get the current version string for use in filenames. + */ + private function getCurrentVersionString(): string + { + return $this->versionManager->getVersion()->toString(); + } + + /** + * Create a backup before updating. + * + * @param string|null $targetVersion Optional target version for naming + * @param string|null $prefix Optional prefix for the backup filename + * @return string The path to the created backup file + */ + public function createBackup(?string $targetVersion = null, ?string $prefix = 'backup'): string + { + $backupDir = $this->getBackupDir(); + + if (!is_dir($backupDir)) { + $this->filesystem->mkdir($backupDir, 0755); + } + + $currentVersion = $this->getCurrentVersionString(); + + // Build filename + if ($targetVersion) { + $targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion); + $backupFile = $backupDir . '/pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.zip'; + } else { + $backupFile = $backupDir . '/' . $prefix . '-v' . $currentVersion . '-' . date('Y-m-d-His') . '.zip'; + } + + $this->commandRunHelper->runCommand([ + 'php', 'bin/console', 'partdb:backup', + '--full', + '--overwrite', + $backupFile, + ], 'Create backup', 600); + + $this->logger->info('Created backup', ['file' => $backupFile]); + + return $backupFile; + } + + /** + * Get list of backups, that are available, sorted by date descending. + * + * @return array + */ + public function getBackups(): array + { + $backupDir = $this->getBackupDir(); + + if (!is_dir($backupDir)) { + return []; + } + + $backups = []; + foreach (glob($backupDir . '/*.zip') as $backupFile) { + $backups[] = [ + 'file' => basename($backupFile), + 'path' => $backupFile, + 'date' => filemtime($backupFile), + 'size' => filesize($backupFile), + ]; + } + + // Sort by date descending + usort($backups, static fn($a, $b) => $b['date'] <=> $a['date']); + + return $backups; + } + + /** + * Get details about a specific backup file. + * + * @param string $filename The backup filename + * @return null|array{file: string, path: string, date: int, size: int, from_version: ?string, to_version: ?string, contains_database?: bool, contains_config?: bool, contains_attachments?: bool} Backup details or null if not found + */ + public function getBackupDetails(string $filename): ?array + { + $backupDir = $this->getBackupDir(); + $backupPath = $backupDir . '/' . basename($filename); + + if (!file_exists($backupPath) || !str_ends_with($backupPath, '.zip')) { + return null; + } + + // Parse version info from filename: pre-update-v2.5.1-to-v2.5.0-2024-01-30-185400.zip + $info = [ + 'file' => basename($backupPath), + 'path' => $backupPath, + 'date' => filemtime($backupPath), + 'size' => filesize($backupPath), + 'from_version' => null, + 'to_version' => null, + ]; + + if (preg_match('/pre-update-v([\d.]+)-to-v?([\d.]+)-/', $filename, $matches)) { + $info['from_version'] = $matches[1]; + $info['to_version'] = $matches[2]; + } + + // Check what the backup contains by reading the ZIP + try { + $zip = new \ZipArchive(); + if ($zip->open($backupPath) === true) { + $info['contains_database'] = $zip->locateName('database.sql') !== false || $zip->locateName('var/app.db') !== false; + $info['contains_config'] = $zip->locateName('.env.local') !== false || $zip->locateName('config/parameters.yaml') !== false; + $info['contains_attachments'] = $zip->locateName('public/media/') !== false || $zip->locateName('uploads/') !== false; + $zip->close(); + } + } catch (\Exception $e) { + $this->logger->warning('Could not read backup ZIP contents', ['error' => $e->getMessage()]); + } + + return $info; + } + + /** + * Delete a backup file. + * + * @param string $filename The backup filename to delete + * @return bool True if deleted successfully + */ + public function deleteBackup(string $filename): bool + { + $backupDir = $this->getBackupDir(); + $backupPath = $backupDir . '/' . basename($filename); + + if (!file_exists($backupPath) || !str_ends_with($backupPath, '.zip')) { + return false; + } + + try { + $this->filesystem->remove($backupPath); + $this->logger->info('Deleted backup', ['file' => $filename]); + return true; + } catch (\Exception $e) { + $this->logger->error('Failed to delete backup', ['file' => $filename, 'error' => $e->getMessage()]); + return false; + } + } + + /** + * Restore from a backup file. + * + * @param string $filename The backup filename to restore + * @param bool $restoreDatabase Whether to restore the database + * @param bool $restoreConfig Whether to restore config files + * @param bool $restoreAttachments Whether to restore attachments + * @param callable|null $onProgress Callback for progress updates + * @return array{success: bool, steps: array, error: ?string} + */ + public function restoreBackup( + string $filename, + bool $restoreDatabase = true, + bool $restoreConfig = false, + bool $restoreAttachments = false, + ?callable $onProgress = null + ): array { + $steps = []; + $startTime = microtime(true); + + $log = function (string $step, string $message, bool $success, ?float $duration = null) use (&$steps, $onProgress): void { + $entry = [ + 'step' => $step, + 'message' => $message, + 'success' => $success, + 'timestamp' => (new \DateTime())->format('c'), + 'duration' => $duration, + ]; + $steps[] = $entry; + $this->logger->info('[Restore] ' . $step . ': ' . $message, ['success' => $success]); + + if ($onProgress) { + $onProgress($entry); + } + }; + + try { + // Validate backup file + $backupDir = $this->getBackupDir(); + $backupPath = $backupDir . '/' . basename($filename); + + if (!file_exists($backupPath)) { + throw new \RuntimeException('Backup file not found: ' . $filename); + } + + $stepStart = microtime(true); + + // Step 1: Extract backup to temp directory + $tempDir = sys_get_temp_dir() . '/partdb_restore_' . uniqid(); + $this->filesystem->mkdir($tempDir); + + $zip = new \ZipArchive(); + if ($zip->open($backupPath) !== true) { + throw new \RuntimeException('Could not open backup ZIP file'); + } + $zip->extractTo($tempDir); + $zip->close(); + $log('extract', 'Extracted backup to temporary directory', true, microtime(true) - $stepStart); + + // Step 2: Restore database if requested and present + if ($restoreDatabase) { + $stepStart = microtime(true); + $this->restoreDatabaseFromBackup($tempDir); + $log('database', 'Restored database', true, microtime(true) - $stepStart); + } + + // Step 3: Restore config files if requested and present + if ($restoreConfig) { + $stepStart = microtime(true); + $this->restoreConfigFromBackup($tempDir); + $log('config', 'Restored configuration files', true, microtime(true) - $stepStart); + } + + // Step 4: Restore attachments if requested and present + if ($restoreAttachments) { + $stepStart = microtime(true); + $this->restoreAttachmentsFromBackup($tempDir); + $log('attachments', 'Restored attachments', true, microtime(true) - $stepStart); + } + + // Step 5: Clean up temp directory + $stepStart = microtime(true); + $this->filesystem->remove($tempDir); + $log('cleanup', 'Cleaned up temporary files', true, microtime(true) - $stepStart); + + $totalDuration = microtime(true) - $startTime; + $log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true); + + return [ + 'success' => true, + 'steps' => $steps, + 'error' => null, + ]; + + } catch (\Throwable $e) { + $this->logger->error('Restore failed: ' . $e->getMessage(), [ + 'exception' => $e, + 'file' => $filename, + ]); + + // Try to clean up + try { + if (isset($tempDir) && is_dir($tempDir)) { + $this->filesystem->remove($tempDir); + } + } catch (\Throwable $cleanupError) { + $this->logger->error('Cleanup after failed restore also failed', ['error' => $cleanupError->getMessage()]); + } + + return [ + 'success' => false, + 'steps' => $steps, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Restore database from backup. + */ + private function restoreDatabaseFromBackup(string $tempDir): void + { + // Check for SQL dump (MySQL/PostgreSQL) + $sqlFile = $tempDir . '/database.sql'; + if (file_exists($sqlFile)) { + // Import SQL using mysql/psql command directly + // First, get database connection params from Doctrine + $connection = $this->entityManager->getConnection(); + $params = $connection->getParams(); + $platform = $connection->getDatabasePlatform(); + + if ($platform instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) { + // Use mysql command to import - need to use shell to handle input redirection + $mysqlCmd = 'mysql'; + if (isset($params['host'])) { + $mysqlCmd .= ' -h ' . escapeshellarg($params['host']); + } + if (isset($params['port'])) { + $mysqlCmd .= ' -P ' . escapeshellarg((string)$params['port']); + } + if (isset($params['user'])) { + $mysqlCmd .= ' -u ' . escapeshellarg($params['user']); + } + if (isset($params['password']) && $params['password']) { + $mysqlCmd .= ' -p' . escapeshellarg($params['password']); + } + if (isset($params['dbname'])) { + $mysqlCmd .= ' ' . escapeshellarg($params['dbname']); + } + $mysqlCmd .= ' < ' . escapeshellarg($sqlFile); + + // Execute using shell + $process = Process::fromShellCommandline($mysqlCmd, $this->projectDir, null, null, 300); + $process->run(); + + if (!$process->isSuccessful()) { + throw new \RuntimeException('MySQL import failed: ' . $process->getErrorOutput()); + } + } elseif ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) { + // Use psql command to import + $psqlCmd = 'psql'; + if (isset($params['host'])) { + $psqlCmd .= ' -h ' . escapeshellarg($params['host']); + } + if (isset($params['port'])) { + $psqlCmd .= ' -p ' . escapeshellarg((string)$params['port']); + } + if (isset($params['user'])) { + $psqlCmd .= ' -U ' . escapeshellarg($params['user']); + } + if (isset($params['dbname'])) { + $psqlCmd .= ' -d ' . escapeshellarg($params['dbname']); + } + $psqlCmd .= ' -f ' . escapeshellarg($sqlFile); + + // Set PGPASSWORD environment variable if password is provided + $env = null; + if (isset($params['password']) && $params['password']) { + $env = ['PGPASSWORD' => $params['password']]; + } + + // Execute using shell + $process = Process::fromShellCommandline($psqlCmd, $this->projectDir, $env, null, 300); + $process->run(); + + if (!$process->isSuccessful()) { + throw new \RuntimeException('PostgreSQL import failed: ' . $process->getErrorOutput()); + } + } else { + throw new \RuntimeException('Unsupported database platform for restore'); + } + + return; + } + + // Check for SQLite database file + $sqliteFile = $tempDir . '/var/app.db'; + if (file_exists($sqliteFile)) { + $targetDb = $this->projectDir . '/var/app.db'; + $this->filesystem->copy($sqliteFile, $targetDb, true); + return; + } + + $this->logger->warning('No database found in backup'); + } + + /** + * Restore config files from backup. + */ + private function restoreConfigFromBackup(string $tempDir): void + { + // Restore .env.local + $envLocal = $tempDir . '/.env.local'; + if (file_exists($envLocal)) { + $this->filesystem->copy($envLocal, $this->projectDir . '/.env.local', true); + } + + // Restore config/parameters.yaml + $parametersYaml = $tempDir . '/config/parameters.yaml'; + if (file_exists($parametersYaml)) { + $this->filesystem->copy($parametersYaml, $this->projectDir . '/config/parameters.yaml', true); + } + + // Restore config/banner.md + $bannerMd = $tempDir . '/config/banner.md'; + if (file_exists($bannerMd)) { + $this->filesystem->copy($bannerMd, $this->projectDir . '/config/banner.md', true); + } + } + + /** + * Restore attachments from backup. + */ + private function restoreAttachmentsFromBackup(string $tempDir): void + { + // Restore public/media + $publicMedia = $tempDir . '/public/media'; + if (is_dir($publicMedia)) { + $this->filesystem->mirror($publicMedia, $this->projectDir . '/public/media', null, ['override' => true]); + } + + // Restore uploads + $uploads = $tempDir . '/uploads'; + if (is_dir($uploads)) { + $this->filesystem->mirror($uploads, $this->projectDir . '/uploads', null, ['override' => true]); + } + } +} diff --git a/src/Services/System/CommandRunHelper.php b/src/Services/System/CommandRunHelper.php new file mode 100644 index 00000000..19bc8548 --- /dev/null +++ b/src/Services/System/CommandRunHelper.php @@ -0,0 +1,73 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\System; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Process\Process; + +class CommandRunHelper +{ + public function __construct( + #[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir + ) + { + } + + /** + * Run a shell command with proper error handling. + */ + public function runCommand(array $command, string $description, int $timeout = 120): string + { + $process = new Process($command, $this->project_dir); + $process->setTimeout($timeout); + + // Set environment variables needed for Composer and other tools + // This is especially important when running as www-data which may not have HOME set + // We inherit from current environment and override/add specific variables + $currentEnv = getenv(); + if (!is_array($currentEnv)) { + $currentEnv = []; + } + $env = array_merge($currentEnv, [ + 'HOME' => $this->project_dir.'/var/www-data-home', + 'COMPOSER_HOME' => $this->project_dir.'/var/composer', + 'PATH' => getenv('PATH') ?: '/usr/local/bin:/usr/bin:/bin', + ]); + $process->setEnv($env); + + $output = ''; + $process->run(function ($type, $buffer) use (&$output) { + $output .= $buffer; + }); + + if (!$process->isSuccessful()) { + $errorOutput = $process->getErrorOutput() ?: $process->getOutput(); + throw new \RuntimeException( + sprintf('%s failed: %s', $description, trim($errorOutput)) + ); + } + + return $output; + } +} diff --git a/src/Services/System/GitVersionInfoProvider.php b/src/Services/System/GitVersionInfoProvider.php new file mode 100644 index 00000000..01925ff8 --- /dev/null +++ b/src/Services/System/GitVersionInfoProvider.php @@ -0,0 +1,141 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\System; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Process\Process; + +/** + * This service provides information about the current Git installation (if any). + */ +final readonly class GitVersionInfoProvider +{ + public function __construct(#[Autowire(param: 'kernel.project_dir')] private string $project_dir) + { + } + + /** + * Check if the project directory is a Git repository. + * @return bool + */ + public function isGitRepo(): bool + { + return is_dir($this->getGitDirectory()); + } + + /** + * Get the path to the Git directory of the installed system without a trailing slash. + * Even if this is no Git installation, the path is returned. + * @return string The path to the Git directory of the installed system + */ + public function getGitDirectory(): string + { + return $this->project_dir . '/.git'; + } + + /** + * Get the Git branch name of the installed system. + * + * @return string|null The current git branch name. Null, if this is no Git installation + */ + public function getBranchName(): ?string + { + if (is_file($this->getGitDirectory() . '/HEAD')) { + $git = file($this->getGitDirectory() . '/HEAD'); + $head = explode('/', $git[0], 3); + + if (!isset($head[2])) { + return null; + } + + return trim($head[2]); + } + + return null; // this is not a Git installation + } + + /** + * Get hash of the last git commit (on remote "origin"!). + * + * If this method does not work, try to make a "git pull" first! + * + * @param int $length if this is smaller than 40, only the first $length characters will be returned + * + * @return string|null The hash of the last commit, null If this is no Git installation + */ + public function getCommitHash(int $length = 8): ?string + { + $path = $this->getGitDirectory() . '/HEAD'; + if (!file_exists($path)) { + return null; + } + + $head = trim(file_get_contents($path)); + + // If it's a symbolic ref (e.g., "ref: refs/heads/main") + if (str_starts_with($head, 'ref:')) { + $refPath = $this->getGitDirectory() . '/' . trim(substr($head, 5)); + if (file_exists($refPath)) { + $hash = trim(file_get_contents($refPath)); + } + } else { + // Otherwise, it's a detached HEAD (the hash is right there) + $hash = $head; + } + + return isset($hash) ? substr($hash, 0, $length) : null; + + } + + /** + * Get the Git remote URL of the installed system. + */ + public function getRemoteURL(): ?string + { + // Get remote URL + $configFile = $this->getGitDirectory() . '/config'; + if (file_exists($configFile)) { + $config = file_get_contents($configFile); + if (preg_match('#url = (.+)#', $config, $matches)) { + return trim($matches[1]); + } + } + + return null; // this is not a Git installation + } + + /** + * Check if there are local changes in the Git repository. + * Attention: This runs a git command, which might be slow! + * @return bool|null True if there are local changes, false if not, null if this is not a Git installation + */ + public function hasLocalChanges(): ?bool + { + $process = new Process(['git', 'status', '--porcelain'], $this->project_dir); + $process->run(); + if (!$process->isSuccessful()) { + return null; // this is not a Git installation + } + return !empty(trim($process->getOutput())); + } +} diff --git a/src/Services/System/InstallationType.php b/src/Services/System/InstallationType.php new file mode 100644 index 00000000..74479bb9 --- /dev/null +++ b/src/Services/System/InstallationType.php @@ -0,0 +1,65 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\System; + +/** + * Detects the installation type of Part-DB to determine the appropriate update strategy. + */ +enum InstallationType: string +{ + case GIT = 'git'; + case DOCKER = 'docker'; + case ZIP_RELEASE = 'zip_release'; + case UNKNOWN = 'unknown'; + + public function getLabel(): string + { + return match ($this) { + self::GIT => 'Git Clone', + self::DOCKER => 'Docker', + self::ZIP_RELEASE => 'Release Archive (ZIP File)', + self::UNKNOWN => 'Unknown', + }; + } + + public function supportsAutoUpdate(): bool + { + return match ($this) { + self::GIT => true, + self::DOCKER => false, + // ZIP_RELEASE auto-update not yet implemented + self::ZIP_RELEASE => false, + self::UNKNOWN => false, + }; + } + + public function getUpdateInstructions(): string + { + return match ($this) { + self::GIT => 'Run: php bin/console partdb:update', + self::DOCKER => 'Pull the new Docker image and recreate the container: docker-compose pull && docker-compose up -d', + self::ZIP_RELEASE => 'Download the new release ZIP from GitHub, extract it over your installation, and run: php bin/console doctrine:migrations:migrate && php bin/console cache:clear', + self::UNKNOWN => 'Unable to determine installation type. Please update manually.', + }; + } +} diff --git a/src/Services/System/InstallationTypeDetector.php b/src/Services/System/InstallationTypeDetector.php new file mode 100644 index 00000000..9f9fbdb8 --- /dev/null +++ b/src/Services/System/InstallationTypeDetector.php @@ -0,0 +1,153 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\System; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Process\Process; + +readonly class InstallationTypeDetector +{ + public function __construct(#[Autowire(param: 'kernel.project_dir')] private string $project_dir, private GitVersionInfoProvider $gitVersionInfoProvider) + { + + } + + /** + * Detect the installation type based on filesystem markers. + */ + public function detect(): InstallationType + { + // Check for Docker environment first + if ($this->isDocker()) { + return InstallationType::DOCKER; + } + + // Check for Git installation + if ($this->isGitInstall()) { + return InstallationType::GIT; + } + + // Check for ZIP release (has VERSION file but no .git) + if ($this->isZipRelease()) { + return InstallationType::ZIP_RELEASE; + } + + return InstallationType::UNKNOWN; + } + + /** + * Check if running inside a Docker container. + */ + public function isDocker(): bool + { + // Check for /.dockerenv file + if (file_exists('/.dockerenv')) { + return true; + } + + // Check for DOCKER environment variable + if (getenv('DOCKER') !== false) { + return true; + } + + // Check for container runtime in cgroup + if (file_exists('/proc/1/cgroup')) { + $cgroup = @file_get_contents('/proc/1/cgroup'); + if ($cgroup !== false && (str_contains($cgroup, 'docker') || str_contains($cgroup, 'containerd'))) { + return true; + } + } + + return false; + } + + /** + * Check if this is a Git-based installation. + */ + public function isGitInstall(): bool + { + return $this->gitVersionInfoProvider->isGitRepo(); + } + + /** + * Check if this appears to be a ZIP release installation. + */ + public function isZipRelease(): bool + { + // Has VERSION file but no .git directory + return file_exists($this->project_dir . '/VERSION') && !$this->isGitInstall(); + } + + /** + * Get detailed information about the installation. + */ + public function getInstallationInfo(): array + { + $type = $this->detect(); + + $info = [ + 'type' => $type, + 'type_name' => $type->getLabel(), + 'supports_auto_update' => $type->supportsAutoUpdate(), + 'update_instructions' => $type->getUpdateInstructions(), + 'project_dir' => $this->project_dir, + ]; + + if ($type === InstallationType::GIT) { + $info['git'] = $this->getGitInfo(); + } + + if ($type === InstallationType::DOCKER) { + $info['docker'] = $this->getDockerInfo(); + } + + return $info; + } + + /** + * Get Git-specific information. + * @return array{branch: string|null, commit: string|null, remote_url: string|null, has_local_changes: bool} + */ + private function getGitInfo(): array + { + return [ + 'branch' => $this->gitVersionInfoProvider->getBranchName(), + 'commit' => $this->gitVersionInfoProvider->getCommitHash(8), + 'remote_url' => $this->gitVersionInfoProvider->getRemoteURL(), + 'has_local_changes' => $this->gitVersionInfoProvider->hasLocalChanges() ?? false, + ]; + } + + /** + * Get Docker-specific information. + * @return array{container_id: string|null, image: string|null} + */ + private function getDockerInfo(): array + { + return [ + 'container_id' => @file_get_contents('/proc/1/cpuset') ?: null, + 'image' => getenv('DOCKER_IMAGE') ?: null, + ]; + } +} diff --git a/src/Services/System/UpdateAvailableManager.php b/src/Services/System/UpdateAvailableFacade.php similarity index 61% rename from src/Services/System/UpdateAvailableManager.php rename to src/Services/System/UpdateAvailableFacade.php index 82cfb84e..ac3a46c0 100644 --- a/src/Services/System/UpdateAvailableManager.php +++ b/src/Services/System/UpdateAvailableFacade.php @@ -24,28 +24,23 @@ declare(strict_types=1); namespace App\Services\System; use App\Settings\SystemSettings\PrivacySettings; -use Psr\Log\LoggerInterface; -use Shivas\VersioningBundle\Service\VersionManagerInterface; -use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; -use Symfony\Contracts\HttpClient\HttpClientInterface; use Version\Version; /** * This class checks if a new version of Part-DB is available. */ -class UpdateAvailableManager +class UpdateAvailableFacade { - - private const API_URL = 'https://api.github.com/repos/Part-DB/Part-DB-server/releases/latest'; private const CACHE_KEY = 'uam_latest_version'; private const CACHE_TTL = 60 * 60 * 24 * 2; // 2 day - public function __construct(private readonly HttpClientInterface $httpClient, - private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager, - private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger, - #[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode) + public function __construct( + private readonly CacheInterface $updateCache, + private readonly PrivacySettings $privacySettings, + private readonly UpdateChecker $updateChecker, + ) { } @@ -89,9 +84,7 @@ class UpdateAvailableManager } $latestVersion = $this->getLatestVersion(); - $currentVersion = $this->versionManager->getVersion(); - - return $latestVersion->isGreaterThan($currentVersion); + return $this->updateChecker->isNewerVersionThanCurrent($latestVersion); } /** @@ -111,34 +104,7 @@ class UpdateAvailableManager return $this->updateCache->get(self::CACHE_KEY, function (ItemInterface $item) { $item->expiresAfter(self::CACHE_TTL); - try { - $response = $this->httpClient->request('GET', self::API_URL); - $result = $response->toArray(); - $tag_name = $result['tag_name']; - - // Remove the leading 'v' from the tag name - $version = substr($tag_name, 1); - - return [ - 'version' => $version, - 'url' => $result['html_url'], - ]; - } catch (\Exception $e) { - //When we are in dev mode, throw the exception, otherwise just silently log it - if ($this->is_dev_mode) { - throw $e; - } - - //In the case of an error, try it again after half of the cache time - $item->expiresAfter(self::CACHE_TTL / 2); - - $this->logger->error('Checking for updates failed: ' . $e->getMessage()); - - return [ - 'version' => '0.0.1', - 'url' => 'update-checking-error' - ]; - } + return $this->updateChecker->getLatestVersion(); }); } -} \ No newline at end of file +} diff --git a/src/Services/System/UpdateChecker.php b/src/Services/System/UpdateChecker.php new file mode 100644 index 00000000..fdb8d9dd --- /dev/null +++ b/src/Services/System/UpdateChecker.php @@ -0,0 +1,338 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\System; + +use App\Settings\SystemSettings\PrivacySettings; +use Psr\Log\LoggerInterface; +use Shivas\VersioningBundle\Service\VersionManagerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Process\Process; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Version\Version; + +/** + * Enhanced update checker that fetches release information including changelogs. + */ +class UpdateChecker +{ + private const GITHUB_API_BASE = 'https://api.github.com/repos/Part-DB/Part-DB-server'; + private const CACHE_KEY_RELEASES = 'update_checker_releases'; + private const CACHE_KEY_COMMITS = 'update_checker_commits_behind'; + private const CACHE_TTL = 60 * 60 * 6; // 6 hours + private const CACHE_TTL_ERROR = 60 * 60; // 1 hour on error + + public function __construct(private readonly HttpClientInterface $httpClient, + private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager, + private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger, + private readonly InstallationTypeDetector $installationTypeDetector, + private readonly GitVersionInfoProvider $gitVersionInfoProvider, + #[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode, + #[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir) + { + + } + + /** + * Get the current installed version. + */ + public function getCurrentVersion(): Version + { + return $this->versionManager->getVersion(); + } + + /** + * Get the current version as string. + */ + public function getCurrentVersionString(): string + { + return $this->getCurrentVersion()->toString(); + } + + /** + * Get Git repository information. + * @return array{branch: ?string, commit: ?string, has_local_changes: bool, commits_behind: int, is_git_install: bool} + */ + private function getGitInfo(): array + { + $info = [ + 'branch' => null, + 'commit' => null, + 'has_local_changes' => false, + 'commits_behind' => 0, + 'is_git_install' => false, + ]; + + if (!$this->gitVersionInfoProvider->isGitRepo()) { + return $info; + } + + $info['is_git_install'] = true; + + $info['branch'] = $this->gitVersionInfoProvider->getBranchName(); + $info['commit'] = $this->gitVersionInfoProvider->getCommitHash(8); + $info['has_local_changes'] = $this->gitVersionInfoProvider->hasLocalChanges(); + + // Get commits behind (fetch first) + if ($info['branch']) { + // Try to get cached commits behind count + $info['commits_behind'] = $this->getCommitsBehind($info['branch']); + } + + return $info; + } + + /** + * Get number of commits behind the remote branch (cached). + */ + private function getCommitsBehind(string $branch): int + { + if (!$this->privacySettings->checkForUpdates) { + return 0; + } + + $cacheKey = self::CACHE_KEY_COMMITS . '_' . hash('xxh3', $branch); + + return $this->updateCache->get($cacheKey, function (ItemInterface $item) use ($branch) { + $item->expiresAfter(self::CACHE_TTL); + + // Fetch from remote first + $process = new Process(['git', 'fetch', '--tags', 'origin'], $this->project_dir); + $process->run(); + + // Count commits behind + $process = new Process(['git', 'rev-list', 'HEAD..origin/' . $branch, '--count'], $this->project_dir); + $process->run(); + + return $process->isSuccessful() ? (int) trim($process->getOutput()) : 0; + }); + } + + /** + * Force refresh git information by invalidating cache. + */ + public function refreshVersionInfo(): void + { + $gitBranch = $this->gitVersionInfoProvider->getBranchName(); + if ($gitBranch) { + $this->updateCache->delete(self::CACHE_KEY_COMMITS . '_' . hash('xxh3', $gitBranch)); + } + $this->updateCache->delete(self::CACHE_KEY_RELEASES); + } + + /** + * Get all available releases from GitHub (cached). + * + * @return array + */ + public function getAvailableReleases(int $limit = 10): array + { + if (!$this->privacySettings->checkForUpdates) { + return []; + } + + return $this->updateCache->get(self::CACHE_KEY_RELEASES, function (ItemInterface $item) use ($limit) { + $item->expiresAfter(self::CACHE_TTL); + + try { + $response = $this->httpClient->request('GET', self::GITHUB_API_BASE . '/releases', [ + 'query' => ['per_page' => $limit], + 'headers' => [ + 'Accept' => 'application/vnd.github.v3+json', + 'User-Agent' => 'Part-DB-Update-Checker', + ], + ]); + + $releases = []; + foreach ($response->toArray() as $release) { + // Extract assets (for ZIP download) + $assets = []; + foreach ($release['assets'] ?? [] as $asset) { + if (str_ends_with($asset['name'], '.zip') || str_ends_with($asset['name'], '.tar.gz')) { + $assets[] = [ + 'name' => $asset['name'], + 'url' => $asset['browser_download_url'], + 'size' => $asset['size'], + ]; + } + } + + $releases[] = [ + 'version' => ltrim($release['tag_name'], 'v'), + 'tag' => $release['tag_name'], + 'name' => $release['name'] ?? $release['tag_name'], + 'url' => $release['html_url'], + 'published_at' => $release['published_at'], + 'body' => $release['body'] ?? '', + 'prerelease' => $release['prerelease'] ?? false, + 'draft' => $release['draft'] ?? false, + 'assets' => $assets, + 'tarball_url' => $release['tarball_url'] ?? null, + 'zipball_url' => $release['zipball_url'] ?? null, + ]; + } + + return $releases; + } catch (\Exception $e) { + $this->logger->error('Failed to fetch releases from GitHub: ' . $e->getMessage()); + $item->expiresAfter(self::CACHE_TTL_ERROR); + + if ($this->is_dev_mode) { + throw $e; + } + + return []; + } + }); + } + + /** + * Get the latest stable release. + * @return array{version: string, tag: string, name: string, url: string, published_at: string, body: string, prerelease: bool, assets: array}|null + */ + public function getLatestVersion(bool $includePrerelease = false): ?array + { + $releases = $this->getAvailableReleases(); + + foreach ($releases as $release) { + // Skip drafts always + if ($release['draft']) { + continue; + } + + // Skip prereleases unless explicitly included + if (!$includePrerelease && $release['prerelease']) { + continue; + } + + return $release; + } + + return null; + } + + /** + * Check if a specific version is newer than current. + */ + public function isNewerVersionThanCurrent(Version|string $version): bool + { + if ($version instanceof Version) { + return $version->isGreaterThan($this->getCurrentVersion()); + } + try { + return Version::fromString(ltrim($version, 'v'))->isGreaterThan($this->getCurrentVersion()); + } catch (\Exception) { + return false; + } + } + + /** + * Get comprehensive update status. + * @return array{current_version: string, latest_version: ?string, latest_tag: ?string, update_available: bool, release_notes: ?string, release_url: ?string, + * published_at: ?string, git: array, installation: array, can_auto_update: bool, update_blockers: array, check_enabled: bool} + */ + public function getUpdateStatus(): array + { + $current = $this->getCurrentVersion(); + $latest = $this->getLatestVersion(); + $gitInfo = $this->getGitInfo(); + $installInfo = $this->installationTypeDetector->getInstallationInfo(); + + $updateAvailable = false; + $latestVersion = null; + $latestTag = null; + + if ($latest) { + try { + $latestVersionObj = Version::fromString($latest['version']); + $updateAvailable = $latestVersionObj->isGreaterThan($current); + $latestVersion = $latest['version']; + $latestTag = $latest['tag']; + } catch (\Exception) { + // Invalid version string + } + } + + // Determine if we can auto-update + $canAutoUpdate = $installInfo['supports_auto_update']; + $updateBlockers = []; + + if ($gitInfo['has_local_changes']) { + $canAutoUpdate = false; + $updateBlockers[] = 'local_changes'; + } + + if ($installInfo['type'] === InstallationType::DOCKER) { + $updateBlockers[] = 'docker_installation'; + } + + return [ + 'current_version' => $current->toString(), + 'latest_version' => $latestVersion, + 'latest_tag' => $latestTag, + 'update_available' => $updateAvailable, + 'release_notes' => $latest['body'] ?? null, + 'release_url' => $latest['url'] ?? null, + 'published_at' => $latest['published_at'] ?? null, + 'git' => $gitInfo, + 'installation' => $installInfo, + 'can_auto_update' => $canAutoUpdate, + 'update_blockers' => $updateBlockers, + 'check_enabled' => $this->privacySettings->checkForUpdates, + ]; + } + + /** + * Get releases newer than the current version. + * @return array + */ + public function getAvailableUpdates(bool $includePrerelease = false): array + { + $releases = $this->getAvailableReleases(); + $current = $this->getCurrentVersion(); + $updates = []; + + foreach ($releases as $release) { + if ($release['draft']) { + continue; + } + + if (!$includePrerelease && $release['prerelease']) { + continue; + } + + try { + $releaseVersion = Version::fromString($release['version']); + if ($releaseVersion->isGreaterThan($current)) { + $updates[] = $release; + } + } catch (\Exception) { + continue; + } + } + + return $updates; + } +} diff --git a/src/Services/System/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php new file mode 100644 index 00000000..2fe54173 --- /dev/null +++ b/src/Services/System/UpdateExecutor.php @@ -0,0 +1,940 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\System; + +use Psr\Log\LoggerInterface; +use Shivas\VersioningBundle\Service\VersionManagerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Process\Process; + +/** + * Handles the execution of Part-DB updates with safety mechanisms. + * + * This service should primarily be used from CLI commands, not web requests, + * due to the long-running nature of updates and permission requirements. + * + * For web requests, use startBackgroundUpdate() method. + */ +class UpdateExecutor +{ + private const LOCK_FILE = 'var/update.lock'; + private const MAINTENANCE_FILE = 'var/maintenance.flag'; + private const UPDATE_LOG_DIR = 'var/log/updates'; + private const PROGRESS_FILE = 'var/update_progress.json'; + + /** @var array */ + private array $steps = []; + + private ?string $currentLogFile = null; + + public function __construct( + #[Autowire(param: 'kernel.project_dir')] + private readonly string $project_dir, + private readonly LoggerInterface $logger, + private readonly Filesystem $filesystem, + private readonly InstallationTypeDetector $installationTypeDetector, + private readonly UpdateChecker $updateChecker, + private readonly BackupManager $backupManager, + private readonly CommandRunHelper $commandRunHelper, + #[Autowire(param: 'app.debug_mode')] + private readonly bool $debugMode = false, + ) { + } + + /** + * Get the current version string for use in filenames. + */ + private function getCurrentVersionString(): string + { + return $this->updateChecker->getCurrentVersionString(); + } + + /** + * Check if an update is currently in progress. + */ + public function isLocked(): bool + { + // Check if lock is stale (older than 1 hour) + $lockData = $this->getLockInfo(); + if ($lockData === null) { + return false; + } + + if ($lockData && isset($lockData['started_at'])) { + $startedAt = new \DateTime($lockData['started_at']); + $now = new \DateTime(); + $diff = $now->getTimestamp() - $startedAt->getTimestamp(); + + // If lock is older than 1 hour, consider it stale + if ($diff > 3600) { + $this->logger->warning('Found stale update lock, removing it'); + $this->releaseLock(); + return false; + } + } + + return true; + } + + /** + * Get lock information, or null if not locked. + * @return null|array{started_at: string, pid: int, user: string} + */ + public function getLockInfo(): ?array + { + $lockFile = $this->project_dir . '/' . self::LOCK_FILE; + + if (!file_exists($lockFile)) { + return null; + } + + return json_decode(file_get_contents($lockFile), true, 512, JSON_THROW_ON_ERROR); + } + + /** + * Check if maintenance mode is enabled. + */ + public function isMaintenanceMode(): bool + { + return file_exists($this->project_dir . '/' . self::MAINTENANCE_FILE); + } + + /** + * Get maintenance mode information. + * @return null|array{enabled_at: string, reason: string} + */ + public function getMaintenanceInfo(): ?array + { + $maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE; + + if (!file_exists($maintenanceFile)) { + return null; + } + + return json_decode(file_get_contents($maintenanceFile), true, 512, JSON_THROW_ON_ERROR); + } + + /** + * Acquire an exclusive lock for the update process. + */ + public function acquireLock(): bool + { + if ($this->isLocked()) { + return false; + } + + $lockFile = $this->project_dir . '/' . self::LOCK_FILE; + $lockDir = dirname($lockFile); + + if (!is_dir($lockDir)) { + $this->filesystem->mkdir($lockDir); + } + + $lockData = [ + 'started_at' => (new \DateTime())->format('c'), + 'pid' => getmypid(), + 'user' => get_current_user(), + ]; + + $this->filesystem->dumpFile($lockFile, json_encode($lockData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + + return true; + } + + /** + * Release the update lock. + */ + public function releaseLock(): void + { + $lockFile = $this->project_dir . '/' . self::LOCK_FILE; + + if (file_exists($lockFile)) { + $this->filesystem->remove($lockFile); + } + } + + /** + * Enable maintenance mode to block user access during update. + */ + public function enableMaintenanceMode(string $reason = 'Update in progress'): void + { + $maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE; + $maintenanceDir = dirname($maintenanceFile); + + if (!is_dir($maintenanceDir)) { + $this->filesystem->mkdir($maintenanceDir); + } + + $data = [ + 'enabled_at' => (new \DateTime())->format('c'), + 'reason' => $reason, + ]; + + $this->filesystem->dumpFile($maintenanceFile, json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + } + + /** + * Disable maintenance mode. + */ + public function disableMaintenanceMode(): void + { + $maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE; + + if (file_exists($maintenanceFile)) { + $this->filesystem->remove($maintenanceFile); + } + } + + /** + * Validate that we can perform an update. + * + * @return array{valid: bool, errors: array} + */ + public function validateUpdatePreconditions(): array + { + $errors = []; + + // Check installation type + $installType = $this->installationTypeDetector->detect(); + if (!$installType->supportsAutoUpdate()) { + $errors[] = sprintf( + 'Installation type "%s" does not support automatic updates. %s', + $installType->getLabel(), + $installType->getUpdateInstructions() + ); + } + + // Check for Git installation + if ($installType === InstallationType::GIT) { + // Check if git is available + $process = new Process(['git', '--version']); + $process->run(); + if (!$process->isSuccessful()) { + $errors[] = 'Git command not found. Please ensure Git is installed and in PATH.'; + } + + // Check for local changes + $process = new Process(['git', 'status', '--porcelain'], $this->project_dir); + $process->run(); + if (!empty(trim($process->getOutput()))) { + $errors[] = 'There are uncommitted local changes. Please commit or stash them before updating.'; + } + } + + // Check if composer is available + $process = new Process(['composer', '--version']); + $process->run(); + if (!$process->isSuccessful()) { + $errors[] = 'Composer command not found. Please ensure Composer is installed and in PATH.'; + } + + // Check if PHP CLI is available + $process = new Process(['php', '--version']); + $process->run(); + if (!$process->isSuccessful()) { + $errors[] = 'PHP CLI not found. Please ensure PHP is installed and in PATH.'; + } + + // Check if yarn is available (for frontend assets) + $process = new Process(['yarn', '--version']); + $process->run(); + if (!$process->isSuccessful()) { + $errors[] = 'Yarn command not found. Please ensure Yarn is installed and in PATH for frontend asset compilation.'; + } + + // Check write permissions + $testDirs = ['var', 'vendor', 'public']; + foreach ($testDirs as $dir) { + $fullPath = $this->project_dir . '/' . $dir; + if (is_dir($fullPath) && !is_writable($fullPath)) { + $errors[] = sprintf('Directory "%s" is not writable.', $dir); + } + } + + // Check if already locked + if ($this->isLocked()) { + $lockInfo = $this->getLockInfo(); + $errors[] = sprintf( + 'An update is already in progress (started at %s).', + $lockInfo['started_at'] ?? 'unknown time' + ); + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + ]; + } + + /** + * Execute the update to a specific version. + * + * @param string $targetVersion The target version/tag to update to (e.g., "v2.6.0") + * @param bool $createBackup Whether to create a backup before updating + * @param callable|null $onProgress Callback for progress updates + * + * @return array{success: bool, steps: array, rollback_tag: ?string, error: ?string, log_file: ?string} + */ + public function executeUpdate( + string $targetVersion, + bool $createBackup = true, + ?callable $onProgress = null + ): array { + $this->steps = []; + $rollbackTag = null; + $startTime = microtime(true); + + // Initialize log file + $this->initializeLogFile($targetVersion); + + $log = function (string $step, string $message, bool $success = true, ?float $duration = null) use ($onProgress): void { + $entry = [ + 'step' => $step, + 'message' => $message, + 'success' => $success, + 'timestamp' => (new \DateTime())->format('c'), + 'duration' => $duration, + ]; + + $this->steps[] = $entry; + $this->writeToLogFile($entry); + $this->logger->info("Update [{$step}]: {$message}", ['success' => $success]); + + if ($onProgress) { + $onProgress($entry); + } + }; + + try { + // Validate preconditions + $validation = $this->validateUpdatePreconditions(); + if (!$validation['valid']) { + throw new \RuntimeException('Precondition check failed: ' . implode('; ', $validation['errors'])); + } + + // Step 1: Acquire lock + $stepStart = microtime(true); + if (!$this->acquireLock()) { + throw new \RuntimeException('Could not acquire update lock. Another update may be in progress.'); + } + $log('lock', 'Acquired exclusive update lock', true, microtime(true) - $stepStart); + + // Step 2: Enable maintenance mode + $stepStart = microtime(true); + $this->enableMaintenanceMode('Updating to ' . $targetVersion); + $log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart); + + // Step 3: Create rollback point with version info + $stepStart = microtime(true); + $currentVersion = $this->getCurrentVersionString(); + $targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion); + $rollbackTag = 'pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His'); + $this->runCommand(['git', 'tag', $rollbackTag], 'Create rollback tag'); + $log('rollback_tag', 'Created rollback tag: ' . $rollbackTag, true, microtime(true) - $stepStart); + + // Step 4: Create backup (optional) + if ($createBackup) { + $stepStart = microtime(true); + $backupFile = $this->backupManager->createBackup($targetVersion); + $log('backup', 'Created backup: ' . basename($backupFile), true, microtime(true) - $stepStart); + } + + // Step 5: Fetch from remote + $stepStart = microtime(true); + $this->runCommand(['git', 'fetch', '--tags', '--force', 'origin'], 'Fetch from origin', 120); + $log('fetch', 'Fetched latest changes and tags from origin', true, microtime(true) - $stepStart); + + // Step 6: Checkout target version + $stepStart = microtime(true); + $this->runCommand(['git', 'checkout', $targetVersion], 'Checkout version'); + $log('checkout', 'Checked out version: ' . $targetVersion, true, microtime(true) - $stepStart); + + // Step 7: Install PHP dependencies + $stepStart = microtime(true); + if ($this->debugMode) { + $this->runCommand([ // Install with dev dependencies in debug mode + 'composer', + 'install', + '--no-interaction', + '--no-progress', + ], 'Install PHP dependencies', 600); + } else { + $this->runCommand([ + 'composer', + 'install', + '--no-dev', + '--optimize-autoloader', + '--no-interaction', + '--no-progress', + ], 'Install PHP dependencies', 600); + } + $log('composer', 'Installed/updated PHP dependencies', true, microtime(true) - $stepStart); + + // Step 8: Install frontend dependencies + $stepStart = microtime(true); + $this->runCommand([ + 'yarn', 'install', + '--frozen-lockfile', + '--non-interactive', + ], 'Install frontend dependencies', 600); + $log('yarn_install', 'Installed frontend dependencies', true, microtime(true) - $stepStart); + + // Step 9: Build frontend assets + $stepStart = microtime(true); + $this->runCommand([ + 'yarn', 'build', + ], 'Build frontend assets', 600); + $log('yarn_build', 'Built frontend assets', true, microtime(true) - $stepStart); + + // Step 10: Run database migrations + $stepStart = microtime(true); + $this->runCommand([ + 'php', 'bin/console', 'doctrine:migrations:migrate', + '--no-interaction', + '--allow-no-migration', + ], 'Run migrations', 300); + $log('migrations', 'Database migrations completed', true, microtime(true) - $stepStart); + + // Step 11: Clear cache + $stepStart = microtime(true); + $this->runCommand([ + 'php', 'bin/console', 'cache:clear', + '--env=prod', + '--no-interaction', + ], 'Clear cache', 120); + $log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart); + + // Step 12: Warm up cache + $stepStart = microtime(true); + $this->runCommand([ + 'php', 'bin/console', 'cache:warmup', + '--env=prod', + ], 'Warmup cache', 120); + $log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart); + + // Step 13: Disable maintenance mode + $stepStart = microtime(true); + $this->disableMaintenanceMode(); + $log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart); + + // Step 14: Release lock + $stepStart = microtime(true); + $this->releaseLock(); + + $totalDuration = microtime(true) - $startTime; + $log('complete', sprintf('Update completed successfully in %.1f seconds', $totalDuration), true, microtime(true) - $stepStart); + + return [ + 'success' => true, + 'steps' => $this->steps, + 'rollback_tag' => $rollbackTag, + 'error' => null, + 'log_file' => $this->currentLogFile, + 'duration' => $totalDuration, + ]; + + } catch (\Exception $e) { + $log('error', 'Update failed: ' . $e->getMessage(), false); + + // Attempt rollback + if ($rollbackTag) { + try { + $this->runCommand(['git', 'checkout', $rollbackTag], 'Rollback'); + $log('rollback', 'Rolled back to: ' . $rollbackTag, true); + + // Re-run composer install after rollback + $this->runCommand([ + 'composer', 'install', + '--no-dev', + '--optimize-autoloader', + '--no-interaction', + ], 'Reinstall dependencies after rollback', 600); + $log('rollback_composer', 'Reinstalled PHP dependencies after rollback', true); + + // Re-run yarn install after rollback + $this->runCommand([ + 'yarn', 'install', + '--frozen-lockfile', + '--non-interactive', + ], 'Reinstall frontend dependencies after rollback', 600); + $log('rollback_yarn_install', 'Reinstalled frontend dependencies after rollback', true); + + // Re-run yarn build after rollback + $this->runCommand([ + 'yarn', 'build', + ], 'Rebuild frontend assets after rollback', 600); + $log('rollback_yarn_build', 'Rebuilt frontend assets after rollback', true); + + // Clear cache after rollback + $this->runCommand([ + 'php', 'bin/console', 'cache:clear', + '--env=prod', + ], 'Clear cache after rollback', 120); + $log('rollback_cache', 'Cleared cache after rollback', true); + + } catch (\Exception $rollbackError) { + $log('rollback_failed', 'Rollback failed: ' . $rollbackError->getMessage(), false); + } + } + + // Clean up + $this->disableMaintenanceMode(); + $this->releaseLock(); + + return [ + 'success' => false, + 'steps' => $this->steps, + 'rollback_tag' => $rollbackTag, + 'error' => $e->getMessage(), + 'log_file' => $this->currentLogFile, + 'duration' => microtime(true) - $startTime, + ]; + } + } + + /** + * Run a shell command with proper error handling. + */ + private function runCommand(array $command, string $description, int $timeout = 120): string + { + return $this->commandRunHelper->runCommand($command, $description, $timeout); + } + + /** + * Initialize the log file for this update. + */ + private function initializeLogFile(string $targetVersion): void + { + $logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR; + + if (!is_dir($logDir)) { + $this->filesystem->mkdir($logDir, 0755); + } + + // Include version numbers in log filename: update-v2.5.1-to-v2.6.0-2024-01-30-185400.log + $currentVersion = $this->getCurrentVersionString(); + $targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion); + $this->currentLogFile = $logDir . '/update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.log'; + + $header = sprintf( + "Part-DB Update Log\n" . + "==================\n" . + "Started: %s\n" . + "From Version: %s\n" . + "Target Version: %s\n" . + "==================\n\n", + date('Y-m-d H:i:s'), + $currentVersion, + $targetVersion + ); + + file_put_contents($this->currentLogFile, $header); + } + + /** + * Write an entry to the log file. + */ + private function writeToLogFile(array $entry): void + { + if (!$this->currentLogFile) { + return; + } + + $line = sprintf( + "[%s] %s: %s%s\n", + $entry['timestamp'], + strtoupper($entry['step']), + $entry['message'], + $entry['duration'] ? sprintf(' (%.2fs)', $entry['duration']) : '' + ); + + file_put_contents($this->currentLogFile, $line, FILE_APPEND); + } + + /** + * Get list of update log files. + * @return array{file: string, path: string, date: int, size: int}[] + */ + public function getUpdateLogs(): array + { + $logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR; + + if (!is_dir($logDir)) { + return []; + } + + $logs = []; + foreach (glob($logDir . '/update-*.log') as $logFile) { + $logs[] = [ + 'file' => basename($logFile), + 'path' => $logFile, + 'date' => filemtime($logFile), + 'size' => filesize($logFile), + ]; + } + + // Sort by date descending + usort($logs, static fn($a, $b) => $b['date'] <=> $a['date']); + + return $logs; + } + + + /** + * Restore from a backup file with maintenance mode and cache clearing. + * + * This wraps BackupManager::restoreBackup with additional safety measures + * like lock acquisition, maintenance mode, and cache operations. + * + * @param string $filename The backup filename to restore + * @param bool $restoreDatabase Whether to restore the database + * @param bool $restoreConfig Whether to restore config files + * @param bool $restoreAttachments Whether to restore attachments + * @param callable|null $onProgress Callback for progress updates + * @return array{success: bool, steps: array, error: ?string} + */ + public function restoreBackup( + string $filename, + bool $restoreDatabase = true, + bool $restoreConfig = false, + bool $restoreAttachments = false, + ?callable $onProgress = null + ): array { + $this->steps = []; + $startTime = microtime(true); + + $log = function (string $step, string $message, bool $success, ?float $duration = null) use ($onProgress): void { + $entry = [ + 'step' => $step, + 'message' => $message, + 'success' => $success, + 'timestamp' => (new \DateTime())->format('c'), + 'duration' => $duration, + ]; + $this->steps[] = $entry; + $this->logger->info('[Restore] ' . $step . ': ' . $message, ['success' => $success]); + + if ($onProgress) { + $onProgress($entry); + } + }; + + try { + $stepStart = microtime(true); + + // Step 1: Acquire lock + if (!$this->acquireLock()) { + throw new \RuntimeException('Could not acquire lock. Another operation may be in progress.'); + } + $log('lock', 'Acquired exclusive restore lock', true, microtime(true) - $stepStart); + + // Step 2: Enable maintenance mode + $stepStart = microtime(true); + $this->enableMaintenanceMode('Restoring from backup...'); + $log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart); + + // Step 3: Delegate to BackupManager for core restoration + $stepStart = microtime(true); + $result = $this->backupManager->restoreBackup( + $filename, + $restoreDatabase, + $restoreConfig, + $restoreAttachments, + function ($entry) use ($log) { + // Forward progress from BackupManager + $log($entry['step'], $entry['message'], $entry['success'], $entry['duration'] ?? null); + } + ); + + if (!$result['success']) { + throw new \RuntimeException($result['error'] ?? 'Restore failed'); + } + + // Step 4: Clear cache + $stepStart = microtime(true); + $this->runCommand(['php', 'bin/console', 'cache:clear', '--no-warmup'], 'Clear cache'); + $log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart); + + // Step 5: Warm up cache + $stepStart = microtime(true); + $this->runCommand(['php', 'bin/console', 'cache:warmup'], 'Warm up cache'); + $log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart); + + // Step 6: Disable maintenance mode + $stepStart = microtime(true); + $this->disableMaintenanceMode(); + $log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart); + + // Step 7: Release lock + $this->releaseLock(); + + $totalDuration = microtime(true) - $startTime; + $log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true); + + return [ + 'success' => true, + 'steps' => $this->steps, + 'error' => null, + ]; + + } catch (\Throwable $e) { + $this->logger->error('Restore failed: ' . $e->getMessage(), [ + 'exception' => $e, + 'file' => $filename, + ]); + + // Try to clean up + try { + $this->disableMaintenanceMode(); + $this->releaseLock(); + } catch (\Throwable $cleanupError) { + $this->logger->error('Cleanup after failed restore also failed', ['error' => $cleanupError->getMessage()]); + } + + return [ + 'success' => false, + 'steps' => $this->steps, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get the path to the progress file. + */ + public function getProgressFilePath(): string + { + return $this->project_dir . '/' . self::PROGRESS_FILE; + } + + /** + * Save progress to file for web UI polling. + * @param array{status: string, target_version: string, create_backup: bool, started_at: string, current_step: int, total_steps: int, step_name: string, step_message: string, steps: array, error: ?string} $progress + */ + private function saveProgress(array $progress): void + { + $progressFile = $this->getProgressFilePath(); + $progressDir = dirname($progressFile); + + if (!is_dir($progressDir)) { + $this->filesystem->mkdir($progressDir); + } + + $this->filesystem->dumpFile($progressFile, json_encode($progress, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + } + + /** + * Get current update progress from file. + * @return null|array{status: string, target_version: string, create_backup: bool, started_at: string, current_step: int, total_steps: int, step_name: string, step_message: string, steps: array, error: ?string} + */ + public function getProgress(): ?array + { + $progressFile = $this->getProgressFilePath(); + + if (!file_exists($progressFile)) { + return null; + } + + $data = json_decode(file_get_contents($progressFile), true, 512, JSON_THROW_ON_ERROR); + + // If the progress file is stale (older than 30 minutes), consider it invalid + if ($data && isset($data['started_at'])) { + $startedAt = strtotime($data['started_at']); + if (time() - $startedAt > 1800) { + $this->clearProgress(); + return null; + } + } + + return $data; + } + + /** + * Clear progress file. + */ + public function clearProgress(): void + { + $progressFile = $this->getProgressFilePath(); + + if (file_exists($progressFile)) { + $this->filesystem->remove($progressFile); + } + } + + /** + * Check if an update is currently running (based on progress file). + */ + public function isUpdateRunning(): bool + { + $progress = $this->getProgress(); + + if (!$progress) { + return false; + } + + return isset($progress['status']) && $progress['status'] === 'running'; + } + + /** + * Start the update process in the background. + * Returns the process ID or null on failure. + */ + public function startBackgroundUpdate(string $targetVersion, bool $createBackup = true): ?int + { + // Validate first + $validation = $this->validateUpdatePreconditions(); + if (!$validation['valid']) { + $this->logger->error('Update validation failed', ['errors' => $validation['errors']]); + return null; + } + + // Initialize progress file + $this->saveProgress([ + 'status' => 'starting', + 'target_version' => $targetVersion, + 'create_backup' => $createBackup, + 'started_at' => (new \DateTime())->format('c'), + 'current_step' => 0, + 'total_steps' => 14, + 'step_name' => 'initializing', + 'step_message' => 'Starting update process...', + 'steps' => [], + 'error' => null, + ]); + + // Build the command to run in background + // Use 'php' from PATH as PHP_BINARY might point to php-fpm + $consolePath = $this->project_dir . '/bin/console'; + $logFile = $this->project_dir . '/var/log/update-background.log'; + + // Ensure log directory exists + $logDir = dirname($logFile); + if (!is_dir($logDir)) { + $this->filesystem->mkdir($logDir, 0755); + } + + //If we are on Windows, we cannot use nohup + if (PHP_OS_FAMILY === 'Windows') { + $command = sprintf( + 'start /B php %s partdb:update %s %s --force --no-interaction >> %s 2>&1', + escapeshellarg($consolePath), + escapeshellarg($targetVersion), + $createBackup ? '' : '--no-backup', + escapeshellarg($logFile) + ); + } else { //Unix like platforms should be able to use nohup + // Use nohup to properly detach the process from the web request + // The process will continue running even after the PHP request ends + $command = sprintf( + 'nohup php %s partdb:update %s %s --force --no-interaction >> %s 2>&1 &', + escapeshellarg($consolePath), + escapeshellarg($targetVersion), + $createBackup ? '' : '--no-backup', + escapeshellarg($logFile) + ); + } + + $this->logger->info('Starting background update', [ + 'command' => $command, + 'target_version' => $targetVersion, + ]); + + // Execute in background using shell_exec for proper detachment + // shell_exec with & runs the command in background + + //@php-ignore-next-line We really need to use shell_exec here + $output = shell_exec($command); + + // Give it a moment to start + usleep(500000); // 500ms + + // Check if progress file was updated (indicates process started) + $progress = $this->getProgress(); + if ($progress && isset($progress['status'])) { + $this->logger->info('Background update started successfully'); + return 1; // Return a non-null value to indicate success + } + + $this->logger->error('Background update may not have started', ['output' => $output]); + return 1; // Still return success as the process might just be slow to start + } + + /** + * Execute update with progress file updates for web UI. + * This is called by the CLI command and updates the progress file. + */ + public function executeUpdateWithProgress( + string $targetVersion, + bool $createBackup = true, + ?callable $onProgress = null + ): array { + $totalSteps = 12; + $currentStep = 0; + + $updateProgress = function (string $stepName, string $message, bool $success = true) use (&$currentStep, $totalSteps, $targetVersion, $createBackup): void { + $currentStep++; + $progress = $this->getProgress() ?? [ + 'status' => 'running', + 'target_version' => $targetVersion, + 'create_backup' => $createBackup, + 'started_at' => (new \DateTime())->format('c'), + 'steps' => [], + ]; + + $progress['current_step'] = $currentStep; + $progress['total_steps'] = $totalSteps; + $progress['step_name'] = $stepName; + $progress['step_message'] = $message; + $progress['status'] = 'running'; + $progress['steps'][] = [ + 'step' => $stepName, + 'message' => $message, + 'success' => $success, + 'timestamp' => (new \DateTime())->format('c'), + ]; + + $this->saveProgress($progress); + }; + + // Wrap the existing executeUpdate with progress tracking + $result = $this->executeUpdate($targetVersion, $createBackup, function ($entry) use ($updateProgress, $onProgress) { + $updateProgress($entry['step'], $entry['message'], $entry['success']); + + if ($onProgress) { + $onProgress($entry); + } + }); + + // Update final status + $finalProgress = $this->getProgress() ?? []; + $finalProgress['status'] = $result['success'] ? 'completed' : 'failed'; + $finalProgress['completed_at'] = (new \DateTime())->format('c'); + $finalProgress['result'] = $result; + $finalProgress['error'] = $result['error']; + $this->saveProgress($finalProgress); + + return $result; + } +} diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index c8afac12..6397e3af 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -325,6 +325,13 @@ class ToolsTreeBuilder ))->setIcon('fa fa-fw fa-gears fa-solid'); } + if ($this->security->isGranted('@system.show_updates')) { + $nodes[] = (new TreeViewNode( + $this->translator->trans('tree.tools.system.update_manager'), + $this->urlGenerator->generate('admin_update_manager') + ))->setIcon('fa-fw fa-treeview fa-solid fa-arrow-circle-up'); + } + return $nodes; } } diff --git a/src/Services/UserSystem/PermissionPresetsHelper.php b/src/Services/UserSystem/PermissionPresetsHelper.php index a3ed01b8..3d125b27 100644 --- a/src/Services/UserSystem/PermissionPresetsHelper.php +++ b/src/Services/UserSystem/PermissionPresetsHelper.php @@ -111,8 +111,9 @@ class PermissionPresetsHelper //Allow to manage Oauth tokens $this->permissionResolver->setPermission($perm_holder, 'system', 'manage_oauth_tokens', PermissionData::ALLOW); - //Allow to show updates + //Allow to show and manage updates $this->permissionResolver->setPermission($perm_holder, 'system', 'show_updates', PermissionData::ALLOW); + $this->permissionResolver->setPermission($perm_holder, 'system', 'manage_updates', PermissionData::ALLOW); } diff --git a/src/State/PartDBInfoProvider.php b/src/State/PartDBInfoProvider.php index b3496cad..b29ef227 100644 --- a/src/State/PartDBInfoProvider.php +++ b/src/State/PartDBInfoProvider.php @@ -7,8 +7,8 @@ namespace App\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; use App\ApiResource\PartDBInfo; -use App\Services\Misc\GitVersionInfo; use App\Services\System\BannerHelper; +use App\Services\System\GitVersionInfoProvider; use App\Settings\SystemSettings\CustomizationSettings; use App\Settings\SystemSettings\LocalizationSettings; use Shivas\VersioningBundle\Service\VersionManagerInterface; @@ -17,7 +17,7 @@ class PartDBInfoProvider implements ProviderInterface { public function __construct(private readonly VersionManagerInterface $versionManager, - private readonly GitVersionInfo $gitVersionInfo, + private readonly GitVersionInfoProvider $gitVersionInfo, private readonly BannerHelper $bannerHelper, private readonly string $default_uri, private readonly LocalizationSettings $localizationSettings, @@ -31,8 +31,8 @@ class PartDBInfoProvider implements ProviderInterface { return new PartDBInfo( version: $this->versionManager->getVersion()->toString(), - git_branch: $this->gitVersionInfo->getGitBranchName(), - git_commit: $this->gitVersionInfo->getGitCommitHash(), + git_branch: $this->gitVersionInfo->getBranchName(), + git_commit: $this->gitVersionInfo->getCommitHash(), title: $this->customizationSettings->instanceName, banner: $this->bannerHelper->getBanner(), default_uri: $this->default_uri, diff --git a/src/Twig/UpdateExtension.php b/src/Twig/UpdateExtension.php new file mode 100644 index 00000000..ee3bb16c --- /dev/null +++ b/src/Twig/UpdateExtension.php @@ -0,0 +1,79 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Twig; + +use App\Services\System\UpdateAvailableFacade; +use Symfony\Bundle\SecurityBundle\Security; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +/** + * Twig extension for update-related functions. + */ +final class UpdateExtension extends AbstractExtension +{ + public function __construct(private readonly UpdateAvailableFacade $updateAvailableManager, + private readonly Security $security) + { + + } + + public function getFunctions(): array + { + return [ + new TwigFunction('is_update_available', $this->isUpdateAvailable(...)), + new TwigFunction('get_latest_version', $this->getLatestVersion(...)), + new TwigFunction('get_latest_version_url', $this->getLatestVersionUrl(...)), + ]; + } + + /** + * Check if an update is available and the user has permission to see it. + */ + public function isUpdateAvailable(): bool + { + // Only show to users with the show_updates permission + if (!$this->security->isGranted('@system.show_updates')) { + return false; + } + + return $this->updateAvailableManager->isUpdateAvailable(); + } + + /** + * Get the latest available version string. + */ + public function getLatestVersion(): string + { + return $this->updateAvailableManager->getLatestVersionString(); + } + + /** + * Get the URL to the latest version release page. + */ + public function getLatestVersionUrl(): string + { + return $this->updateAvailableManager->getLatestVersionUrl(); + } +} diff --git a/templates/_navbar.html.twig b/templates/_navbar.html.twig index c4dfbe0f..d327a4f6 100644 --- a/templates/_navbar.html.twig +++ b/templates/_navbar.html.twig @@ -82,6 +82,19 @@