diff --git a/.env b/.env index 3ba3d65d..9a6ce846 100644 --- a/.env +++ b/.env @@ -59,17 +59,6 @@ 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 8f87ff7d..73462a5a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.6.0-dev +2.5.1 diff --git a/assets/controllers/backup_restore_controller.js b/assets/controllers/backup_restore_controller.js deleted file mode 100644 index 85ee327b..00000000 --- a/assets/controllers/backup_restore_controller.js +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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 deleted file mode 100644 index c30a433e..00000000 --- a/assets/controllers/update_confirm_controller.js +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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 36dd461e..f7a181a8 100644 --- a/composer.json +++ b/composer.json @@ -11,14 +11,12 @@ "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", "api-platform/symfony": "^4.0.0", "beberlei/doctrineextensions": "^1.2", "brick/math": "^0.13.1", - "brick/schema": "^0.2.0", "composer/ca-bundle": "^1.5", "composer/package-versions-deprecated": "^1.11.99.5", "doctrine/data-fixtures": "^2.0.0", diff --git a/composer.lock b/composer.lock index 28d7c981..2ee826f6 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": "7ca9c95fb85f6bf3d9b8a3aa98ca33f6", + "content-hash": "ec69ea04bcf5c1ebd8bb0280a5bb9565", "packages": [ { "name": "amphp/amp", @@ -2387,117 +2387,6 @@ ], "time": "2025-03-29T13:50:30+00:00" }, - { - "name": "brick/schema", - "version": "0.2.0", - "source": { - "type": "git", - "url": "https://github.com/brick/schema.git", - "reference": "b5114bf5e8092430041a37efe1cfd5279ca764c0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/brick/schema/zipball/b5114bf5e8092430041a37efe1cfd5279ca764c0", - "reference": "b5114bf5e8092430041a37efe1cfd5279ca764c0", - "shasum": "" - }, - "require": { - "brick/structured-data": "~0.1.0 || ~0.2.0", - "ext-dom": "*", - "php": "^8.1" - }, - "require-dev": { - "brick/varexporter": "^0.6", - "vimeo/psalm": "6.12.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Brick\\Schema\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Schema.org library for PHP", - "keywords": [ - "JSON-LD", - "brick", - "microdata", - "rdfa lite", - "schema", - "schema.org", - "structured data" - ], - "support": { - "issues": "https://github.com/brick/schema/issues", - "source": "https://github.com/brick/schema/tree/0.2.0" - }, - "funding": [ - { - "url": "https://github.com/BenMorel", - "type": "github" - } - ], - "time": "2025-06-12T07:03:20+00:00" - }, - { - "name": "brick/structured-data", - "version": "0.2.0", - "source": { - "type": "git", - "url": "https://github.com/brick/structured-data.git", - "reference": "be9b28720e2aba87f19c90500700970be85affde" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/brick/structured-data/zipball/be9b28720e2aba87f19c90500700970be85affde", - "reference": "be9b28720e2aba87f19c90500700970be85affde", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "php": "^8.1", - "sabre/uri": "^2.1 || ^3.0" - }, - "require-dev": { - "php-coveralls/php-coveralls": "^2.0", - "phpunit/phpunit": "^8.0 || ^9.0", - "vimeo/psalm": "6.12.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Brick\\StructuredData\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Microdata, RDFa Lite & JSON-LD structured data reader", - "keywords": [ - "JSON-LD", - "brick", - "microdata", - "rdfa", - "structured data" - ], - "support": { - "issues": "https://github.com/brick/structured-data/issues", - "source": "https://github.com/brick/structured-data/tree/0.2.0" - }, - "funding": [ - { - "url": "https://github.com/BenMorel", - "type": "github" - } - ], - "time": "2025-06-10T23:48:46+00:00" - }, { "name": "composer/ca-bundle", "version": "1.5.10", @@ -5108,16 +4997,16 @@ }, { "name": "jbtronics/settings-bundle", - "version": "v3.2.0", + "version": "v3.1.3", "source": { "type": "git", "url": "https://github.com/jbtronics/settings-bundle.git", - "reference": "6a66c099460fd623d0d1ddbf9864b3173d416c3b" + "reference": "a99c6e4cde40b829c1643b89da506b9588b11eaf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jbtronics/settings-bundle/zipball/6a66c099460fd623d0d1ddbf9864b3173d416c3b", - "reference": "6a66c099460fd623d0d1ddbf9864b3173d416c3b", + "url": "https://api.github.com/repos/jbtronics/settings-bundle/zipball/a99c6e4cde40b829c1643b89da506b9588b11eaf", + "reference": "a99c6e4cde40b829c1643b89da506b9588b11eaf", "shasum": "" }, "require": { @@ -5178,7 +5067,7 @@ ], "support": { "issues": "https://github.com/jbtronics/settings-bundle/issues", - "source": "https://github.com/jbtronics/settings-bundle/tree/v3.2.0" + "source": "https://github.com/jbtronics/settings-bundle/tree/v3.1.3" }, "funding": [ { @@ -5190,7 +5079,7 @@ "type": "github" } ], - "time": "2026-02-03T20:13:02+00:00" + "time": "2026-01-02T23:58:02+00:00" }, { "name": "jfcherng/php-color-output", @@ -7302,16 +7191,16 @@ }, { "name": "nette/utils", - "version": "v4.1.2", + "version": "v4.1.1", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5" + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", "shasum": "" }, "require": { @@ -7324,7 +7213,7 @@ "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", "nette/tester": "^2.5", - "phpstan/phpstan": "^2.0@stable", + "phpstan/phpstan-nette": "^2.0@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -7385,9 +7274,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.2" + "source": "https://github.com/nette/utils/tree/v4.1.1" }, - "time": "2026-02-03T17:21:09+00:00" + "time": "2025-12-22T12:14:32+00:00" }, { "name": "nikolaposa/version", @@ -9706,66 +9595,6 @@ }, "time": "2025-09-14T07:37:21+00:00" }, - { - "name": "sabre/uri", - "version": "3.0.2", - "source": { - "type": "git", - "url": "https://github.com/sabre-io/uri.git", - "reference": "38eeab6ed9eec435a2188db489d4649c56272c51" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sabre-io/uri/zipball/38eeab6ed9eec435a2188db489d4649c56272c51", - "reference": "38eeab6ed9eec435a2188db489d4649c56272c51", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3.64", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^1.12", - "phpstan/phpstan-phpunit": "^1.4", - "phpstan/phpstan-strict-rules": "^1.6", - "phpunit/phpunit": "^9.6" - }, - "type": "library", - "autoload": { - "files": [ - "lib/functions.php" - ], - "psr-4": { - "Sabre\\Uri\\": "lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Evert Pot", - "email": "me@evertpot.com", - "homepage": "http://evertpot.com/", - "role": "Developer" - } - ], - "description": "Functions for making sense out of URIs.", - "homepage": "http://sabre.io/uri/", - "keywords": [ - "rfc3986", - "uri", - "url" - ], - "support": { - "forum": "https://groups.google.com/group/sabredav-discuss", - "issues": "https://github.com/sabre-io/uri/issues", - "source": "https://github.com/fruux/sabre-uri" - }, - "time": "2024-09-04T15:30:08+00:00" - }, { "name": "scheb/2fa-backup-code", "version": "v7.13.1", @@ -18782,28 +18611,28 @@ }, { "name": "phpunit/php-file-iterator", - "version": "5.1.1", + "version": "5.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", - "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -18831,27 +18660,15 @@ "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.1" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" }, "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": "2026-02-02T13:52:54+00:00" + "time": "2024-08-27T05:02:59+00:00" }, { "name": "phpunit/php-invoker", @@ -19212,12 +19029,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "57534122edd70a2b3dbb02b65f2091efc57e4ab7" + "reference": "8457f2008fc6396be788162c4e04228028306534" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57534122edd70a2b3dbb02b65f2091efc57e4ab7", - "reference": "57534122edd70a2b3dbb02b65f2091efc57e4ab7", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/8457f2008fc6396be788162c4e04228028306534", + "reference": "8457f2008fc6396be788162c4e04228028306534", "shasum": "" }, "conflict": { @@ -19327,7 +19144,6 @@ "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", @@ -19359,8 +19175,6 @@ "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", @@ -19378,7 +19192,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.8", + "devcode-it/openstamanager": "<=2.9.4", "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", @@ -19461,18 +19275,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.3,<1.3.35", + "ezsystems/ezplatform-kernel": "<1.2.5.1-dev|>=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.1|>=7,<7.5.31", + "ezsystems/ezpublish-kernel": "<6.13.8.2-dev|>=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.81", + "facturascripts/facturascripts": "<=2025.4|==2025.11|==2025.41|==2025.43", "fastly/magento2": "<1.2.26", "feehi/cms": "<=2.1.1", "feehi/feehicms": "<=2.1.1", @@ -19761,7 +19575,7 @@ "open-web-analytics/open-web-analytics": "<1.8.1", "opencart/opencart": ">=0", "openid/php-openid": "<2.3", - "openmage/magento-lts": "<20.16.1", + "openmage/magento-lts": "<20.16", "opensolutions/vimbadmin": "<=3.0.15", "opensource-workshop/connect-cms": "<1.8.7|>=2,<2.4.7", "orchid/platform": ">=8,<14.43", @@ -20223,7 +20037,7 @@ "type": "tidelift" } ], - "time": "2026-02-03T19:20:38+00:00" + "time": "2026-01-30T22:06:58+00:00" }, { "name": "sebastian/cli-parser", @@ -21750,8 +21564,7 @@ "ext-iconv": "*", "ext-intl": "*", "ext-json": "*", - "ext-mbstring": "*", - "ext-zip": "*" + "ext-mbstring": "*" }, "platform-dev": {}, "platform-overrides": { diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 846033d6..6adea442 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -23,7 +23,3 @@ 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 b3d209f6..c16d1804 100644 --- a/config/packages/settings.yaml +++ b/config/packages/settings.yaml @@ -3,7 +3,6 @@ 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 0dabf9d3..8c6a145e 100644 --- a/config/permissions.yaml +++ b/config/permissions.yaml @@ -297,10 +297,6 @@ 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 a1a077aa..82bdc45e 100644 --- a/config/reference.php +++ b/config/reference.php @@ -2387,7 +2387,6 @@ 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 deleted file mode 100644 index 62b35ffd..00000000 Binary files a/docs/screenshots/update-manager-interface.png and /dev/null differ diff --git a/docs/usage/console_commands.md b/docs/usage/console_commands.md index 576b3314..b42bb757 100644 --- a/docs/usage/console_commands.md +++ b/docs/usage/console_commands.md @@ -50,14 +50,6 @@ 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 deleted file mode 100644 index 43fe2c94..00000000 --- a/docs/usage/update_manager.md +++ /dev/null @@ -1,170 +0,0 @@ ---- -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 new file mode 100644 index 00000000..bc4d0bf3 --- /dev/null +++ b/makefile @@ -0,0 +1,91 @@ +# 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 eb629314..fc7b3524 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -6,9 +6,6 @@ parameters: - src # - tests - banned_code: - non_ignorable: false # Allow to ignore some banned code - excludePaths: - src/DataTables/Adapter/* - src/Configuration/* @@ -64,6 +61,3 @@ 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 deleted file mode 100644 index 47b1eaef..00000000 --- a/src/Command/MaintenanceModeCommand.php +++ /dev/null @@ -1,141 +0,0 @@ -. - */ - -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 deleted file mode 100644 index ca6c8399..00000000 --- a/src/Command/UpdateCommand.php +++ /dev/null @@ -1,445 +0,0 @@ -. - */ - -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 d09def8f..d2ce75e1 100644 --- a/src/Command/VersionCommand.php +++ b/src/Command/VersionCommand.php @@ -22,9 +22,9 @@ declare(strict_types=1); */ namespace App\Command; -use App\Services\System\GitVersionInfoProvider; -use Shivas\VersioningBundle\Service\VersionManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; +use App\Services\Misc\GitVersionInfo; +use Shivas\VersioningBundle\Service\VersionManagerInterface; 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 GitVersionInfoProvider $gitVersionInfo) + public function __construct(protected VersionManagerInterface $versionManager, protected GitVersionInfo $gitVersionInfo) { parent::__construct(); } @@ -48,9 +48,9 @@ class VersionCommand extends Command $message = 'Part-DB version: '. $this->versionManager->getVersion()->toString(); - if ($this->gitVersionInfo->getBranchName() !== null) { - $message .= ' Git branch: '. $this->gitVersionInfo->getBranchName(); - $message .= ', Git commit: '. $this->gitVersionInfo->getCommitHash(); + if ($this->gitVersionInfo->getGitBranchName() !== null) { + $message .= ' Git branch: '. $this->gitVersionInfo->getGitBranchName(); + $message .= ', Git commit: '. $this->gitVersionInfo->getGitCommitHash(); } $io->success($message); diff --git a/src/Controller/HomepageController.php b/src/Controller/HomepageController.php index 6f863a3c..076e790b 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\GitVersionInfoProvider; -use App\Services\System\UpdateAvailableFacade; +use App\Services\System\UpdateAvailableManager; 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, GitVersionInfoProvider $versionInfo, EntityManagerInterface $entityManager, - UpdateAvailableFacade $updateAvailableManager): Response + public function homepage(Request $request, GitVersionInfo $versionInfo, EntityManagerInterface $entityManager, + UpdateAvailableManager $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->getBranchName(), - 'git_commit' => $versionInfo->getCommitHash(), + 'git_branch' => $versionInfo->getGitBranchName(), + 'git_commit' => $versionInfo->getGitCommitHash(), '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 76dffb4d..d78aff62 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\System\GitVersionInfoProvider; -use App\Services\System\UpdateAvailableFacade; +use App\Services\Misc\GitVersionInfo; +use App\Services\System\UpdateAvailableManager; 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(GitVersionInfoProvider $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper, - AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableFacade $updateAvailableManager, + public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper, + AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $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->getBranchName(), - 'git_commit' => $versionInfo->getCommitHash(), + 'git_branch' => $versionInfo->getGitBranchName(), + 'git_commit' => $versionInfo->getGitCommitHash(), '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 deleted file mode 100644 index 474c86fc..00000000 --- a/src/Controller/UpdateManagerController.php +++ /dev/null @@ -1,371 +0,0 @@ -. - */ - -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 deleted file mode 100644 index 654ba9f2..00000000 --- a/src/EventSubscriber/MaintenanceModeSubscriber.php +++ /dev/null @@ -1,230 +0,0 @@ -. - */ - -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/InfoProviderSystem/Providers/GenericWebProvider.php b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php index 6d27beb2..66d45707 100644 --- a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php +++ b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php @@ -32,14 +32,6 @@ use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\InfoProviderSystem\PartInfoRetriever; use App\Services\InfoProviderSystem\ProviderRegistry; use App\Settings\InfoProviderSystem\GenericWebProviderSettings; -use Brick\Schema\Interfaces\BreadcrumbList; -use Brick\Schema\Interfaces\ImageObject; -use Brick\Schema\Interfaces\Product; -use Brick\Schema\Interfaces\PropertyValue; -use Brick\Schema\Interfaces\QuantitativeValue; -use Brick\Schema\Interfaces\Thing; -use Brick\Schema\SchemaReader; -use Brick\Schema\SchemaTypeList; use Symfony\Component\DomCrawler\Crawler; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -112,148 +104,126 @@ class GenericWebProvider implements InfoProviderInterface return $host; } - private function breadcrumbToCategory(?BreadcrumbList $breadcrumbList): ?string + private function productJsonLdToPart(array $jsonLd, string $url, Crawler $dom): PartDetailDTO { - if ($breadcrumbList === null) { - return null; - } - - $items = $breadcrumbList->itemListElement->getValues(); - if (count($items) < 1) { - return null; - } - - try { - //Build our category from the breadcrumb items - $categories = []; - foreach ($items as $item) { - if (isset($item->name)) { - $categories[] = trim($item->name->toString()); - } - } - } catch (\Throwable) { - return null; - } - - return implode(' -> ', $categories); - } - - private function productToPart(Product $product, string $url, Crawler $dom, ?BreadcrumbList $categoryBreadcrumb): PartDetailDTO - { - $notes = $product->description->toString() ?? ""; - if ($product->disambiguatingDescription !== null) { + $notes = $jsonLd['description'] ?? ""; + if (isset($jsonLd['disambiguatingDescription'])) { if (!empty($notes)) { $notes .= "\n\n"; } - $notes .= $product->disambiguatingDescription->toString(); + $notes .= $jsonLd['disambiguatingDescription']; } - - //Extract vendor infos $vendor_infos = null; - $offer = $product->offers->getFirstValue(); - if ($offer !== null) { + if (isset($jsonLd['offers'])) { + + if (array_is_list($jsonLd['offers'])) { + $offer = $jsonLd['offers'][0]; + } else { + $offer = $jsonLd['offers']; + } + + //Make $jsonLd['url'] absolute if it's relative + if (isset($jsonLd['url']) && parse_url($jsonLd['url'], PHP_URL_SCHEME) === null) { + $parsedUrl = parse_url($url); + $scheme = $parsedUrl['scheme'] ?? 'https'; + $host = $parsedUrl['host'] ?? ''; + $jsonLd['url'] = $scheme.'://'.$host.$jsonLd['url']; + } + $prices = []; - if ($offer->price->toString() !== null) { - $prices = [new PriceDTO( + if (isset($offer['price'])) { + $prices[] = new PriceDTO( minimum_discount_amount: 1, - price: $offer->price->toString(), - currency_iso_code: $offer->priceCurrency?->toString() - )]; - } else { //Check for nested offers (like IKEA does it) - $offer2 = $offer->offers->getFirstValue(); - if ($offer2 !== null && $offer2->price->toString() !== null) { - $prices = [ - new PriceDTO( + price: (string) $offer['price'], + currency_iso_code: $offer['priceCurrency'] ?? null + ); + } else if (isset($offer['offers']) && array_is_list($offer['offers'])) { + //Some sites nest offers + foreach ($offer['offers'] as $subOffer) { + if (isset($subOffer['price'])) { + $prices[] = new PriceDTO( minimum_discount_amount: 1, - price: $offer2->price->toString(), - currency_iso_code: $offer2->priceCurrency?->toString() - ) - ]; + price: (string) $subOffer['price'], + currency_iso_code: $subOffer['priceCurrency'] ?? null + ); + } } } $vendor_infos = [new PurchaseInfoDTO( distributor_name: $this->extractShopName($url), - order_number: $product->sku?->toString() ?? $product->identifier?->toString() ?? 'Unknown', + order_number: (string) ($jsonLd['sku'] ?? $jsonLd['@id'] ?? $jsonLd['gtin'] ?? 'Unknown'), prices: $prices, - product_url: $offer->url?->toString() ?? $url, + product_url: $jsonLd['url'] ?? $url, )]; } - //Extract image: $image = null; - if ($product->image !== null) { - $imageObj = $product->image->getFirstValue(); - if (is_string($imageObj)) { - $image = $imageObj; - } else if ($imageObj instanceof ImageObject) { - $image = $imageObj->contentUrl?->toString() ?? $imageObj->url?->toString(); + if (isset($jsonLd['image'])) { + if (is_array($jsonLd['image'])) { + if (array_is_list($jsonLd['image'])) { + $image = $jsonLd['image'][0] ?? null; + } + } elseif (is_string($jsonLd['image'])) { + $image = $jsonLd['image']; } } + //If image is an object with @type ImageObject, extract the url + if (is_array($image) && isset($image['@type']) && $image['@type'] === 'ImageObject') { + $image = $image['contentUrl'] ?? $image['url'] ?? null; + } - //Extract parameters from additionalProperty + //Try to extract parameters from additionalProperty $parameters = []; - foreach ($product->additionalProperty->getValues() as $property) { - if ($property instanceof PropertyValue) { //TODO: Handle minValue and maxValue - if ($property->unitText->toString() !== null) { + if (isset($jsonLd['additionalProperty']) && array_is_list($jsonLd['additionalProperty'])) { + foreach ($jsonLd['additionalProperty'] as $property) { //TODO: Handle minValue and maxValue + if (isset ($property['unitText'])) { $parameters[] = ParameterDTO::parseValueField( - name: $property->name->toString() ?? 'Unknown', - value: $property->value->toString() ?? '', - unit: $property->unitText->toString() + name: $property['name'] ?? 'Unknown', + value: $property['value'] ?? '', + unit: $property['unitText'] ); } else { $parameters[] = ParameterDTO::parseValueIncludingUnit( - name: $property->name->toString() ?? 'Unknown', - value: $property->value->toString() ?? '' + name: $property['name'] ?? 'Unknown', + value: $property['value'] ?? '' ); } } } - //Try to extract weight - $mass = null; - if (($weight = $product?->weight->getFirstValue()) instanceof QuantitativeValue) { - $mass = $weight->value->toString(); - } return new PartDetailDTO( provider_key: $this->getProviderKey(), provider_id: $url, - name: $product->name?->toString() ?? $product->alternateName?->toString() ?? $product?->mpn->toString() ?? 'Unknown Name', + name: $jsonLd ['name'] ?? 'Unknown Name', description: $this->getMetaContent($dom, 'og:description') ?? $this->getMetaContent($dom, 'description') ?? '', - category: $this->breadcrumbToCategory($categoryBreadcrumb) ?? $product->category?->toString(), - manufacturer: self::propertyOrString($product->manufacturer) ?? self::propertyOrString($product->brand), - mpn: $product->mpn?->toString(), + category: isset($jsonLd['category']) && is_string($jsonLd['category']) ? $jsonLd['category'] : null, + manufacturer: $jsonLd['manufacturer']['name'] ?? $jsonLd['brand']['name'] ?? null, + mpn: $jsonLd['mpn'] ?? null, preview_image_url: $image, provider_url: $url, notes: $notes, parameters: $parameters, vendor_infos: $vendor_infos, - mass: $mass + mass: isset($jsonLd['weight']['value']) ? (float)$jsonLd['weight']['value'] : null, ); } - private static function propertyOrString(SchemaTypeList|Thing|string|null $value, string $property = "name"): ?string + /** + * Decodes JSON in a forgiving way, trying to fix common issues. + * @param string $json + * @return array + * @throws \JsonException + */ + private function json_decode_forgiving(string $json): array { - if ($value instanceof SchemaTypeList) { - $value = $value->getFirstValue(); - } - if ($value === null) { - return null; - } - - if (is_string($value)) { - return $value; - } - - if ($value instanceof Thing) { - return $value->$property?->toString(); - } - return null; + //Sanitize common issues + $json = preg_replace("/[\r\n]+/", " ", $json); + return json_decode($json, true, 512, JSON_THROW_ON_ERROR); } - /** * Gets the content of a meta tag by its name or property attribute, or null if not found * @param Crawler $dom @@ -366,23 +336,18 @@ class GenericWebProvider implements InfoProviderInterface $canonicalURL = $scheme.'://'.$host.$canonicalURL; } - - $schemaReader = SchemaReader::forAllFormats(); - $things = $schemaReader->readHtml($content, $canonicalURL); - - //Try to find a breadcrumb schema to extract the category - $categoryBreadCrumbs = null; - foreach ($things as $thing) { - if ($thing instanceof BreadcrumbList) { - $categoryBreadCrumbs = $thing; - break; + //Try to find json-ld data in the head + $jsonLdNodes = $dom->filter('script[type="application/ld+json"]'); + foreach ($jsonLdNodes as $node) { + $jsonLd = $this->json_decode_forgiving($node->textContent); + //If the content of json-ld is an array, try to find a product inside + if (!array_is_list($jsonLd)) { + $jsonLd = [$jsonLd]; } - } - - //Try to find a Product schema - foreach ($things as $thing) { - if ($thing instanceof Product) { - return $this->productToPart($thing, $canonicalURL, $dom, $categoryBreadCrumbs); + foreach ($jsonLd as $item) { + if (isset($item['@type']) && $item['@type'] === 'Product') { + return $this->productJsonLdToPart($item, $canonicalURL, $dom); + } } } diff --git a/src/Services/Misc/GitVersionInfo.php b/src/Services/Misc/GitVersionInfo.php new file mode 100644 index 00000000..3c079f4f --- /dev/null +++ b/src/Services/Misc/GitVersionInfo.php @@ -0,0 +1,83 @@ +. + */ + +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 deleted file mode 100644 index 9bdc7f71..00000000 --- a/src/Services/System/BackupManager.php +++ /dev/null @@ -1,453 +0,0 @@ -. - */ - -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 deleted file mode 100644 index 19bc8548..00000000 --- a/src/Services/System/CommandRunHelper.php +++ /dev/null @@ -1,73 +0,0 @@ -. - */ - -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 deleted file mode 100644 index 01925ff8..00000000 --- a/src/Services/System/GitVersionInfoProvider.php +++ /dev/null @@ -1,141 +0,0 @@ -. - */ - -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 deleted file mode 100644 index 74479bb9..00000000 --- a/src/Services/System/InstallationType.php +++ /dev/null @@ -1,65 +0,0 @@ -. - */ - -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 deleted file mode 100644 index 9f9fbdb8..00000000 --- a/src/Services/System/InstallationTypeDetector.php +++ /dev/null @@ -1,153 +0,0 @@ -. - */ - -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/UpdateAvailableFacade.php b/src/Services/System/UpdateAvailableManager.php similarity index 61% rename from src/Services/System/UpdateAvailableFacade.php rename to src/Services/System/UpdateAvailableManager.php index ac3a46c0..82cfb84e 100644 --- a/src/Services/System/UpdateAvailableFacade.php +++ b/src/Services/System/UpdateAvailableManager.php @@ -24,23 +24,28 @@ 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 UpdateAvailableFacade +class UpdateAvailableManager { + + 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 CacheInterface $updateCache, - private readonly PrivacySettings $privacySettings, - private readonly UpdateChecker $updateChecker, - ) + 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) { } @@ -84,7 +89,9 @@ class UpdateAvailableFacade } $latestVersion = $this->getLatestVersion(); - return $this->updateChecker->isNewerVersionThanCurrent($latestVersion); + $currentVersion = $this->versionManager->getVersion(); + + return $latestVersion->isGreaterThan($currentVersion); } /** @@ -104,7 +111,34 @@ class UpdateAvailableFacade return $this->updateCache->get(self::CACHE_KEY, function (ItemInterface $item) { $item->expiresAfter(self::CACHE_TTL); - return $this->updateChecker->getLatestVersion(); + 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' + ]; + } }); } -} +} \ No newline at end of file diff --git a/src/Services/System/UpdateChecker.php b/src/Services/System/UpdateChecker.php deleted file mode 100644 index fdb8d9dd..00000000 --- a/src/Services/System/UpdateChecker.php +++ /dev/null @@ -1,338 +0,0 @@ -. - */ - -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 deleted file mode 100644 index 2fe54173..00000000 --- a/src/Services/System/UpdateExecutor.php +++ /dev/null @@ -1,940 +0,0 @@ -. - */ - -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 6397e3af..c8afac12 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -325,13 +325,6 @@ 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 3d125b27..a3ed01b8 100644 --- a/src/Services/UserSystem/PermissionPresetsHelper.php +++ b/src/Services/UserSystem/PermissionPresetsHelper.php @@ -111,9 +111,8 @@ class PermissionPresetsHelper //Allow to manage Oauth tokens $this->permissionResolver->setPermission($perm_holder, 'system', 'manage_oauth_tokens', PermissionData::ALLOW); - //Allow to show and manage updates + //Allow to show 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 b29ef227..b3496cad 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 GitVersionInfoProvider $gitVersionInfo, + private readonly GitVersionInfo $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->getBranchName(), - git_commit: $this->gitVersionInfo->getCommitHash(), + git_branch: $this->gitVersionInfo->getGitBranchName(), + git_commit: $this->gitVersionInfo->getGitCommitHash(), 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 deleted file mode 100644 index ee3bb16c..00000000 --- a/src/Twig/UpdateExtension.php +++ /dev/null @@ -1,79 +0,0 @@ -. - */ - -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 d327a4f6..c4dfbe0f 100644 --- a/templates/_navbar.html.twig +++ b/templates/_navbar.html.twig @@ -82,19 +82,6 @@