From 705e71f1ebdf6825a375f3de96ee278921ecba9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 25 Jan 2026 20:13:04 +0100 Subject: [PATCH 01/78] Started working on a conrad provider --- .../Providers/ConradProvider.php | 103 ++++++++++++++++++ .../InfoProviderSystem/ConradSettings.php | 69 ++++++++++++ .../InfoProviderSettings.php | 5 +- 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/Services/InfoProviderSystem/Providers/ConradProvider.php create mode 100644 src/Settings/InfoProviderSystem/ConradSettings.php diff --git a/src/Services/InfoProviderSystem/Providers/ConradProvider.php b/src/Services/InfoProviderSystem/Providers/ConradProvider.php new file mode 100644 index 00000000..b72be0bd --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/ConradProvider.php @@ -0,0 +1,103 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use App\Settings\InfoProviderSystem\ConradSettings; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +readonly class ConradProvider implements InfoProviderInterface +{ + + private const SEARCH_ENDPOINT = 'https://api.conrad.de/search/1/v3/facetSearch'; + + public function __construct(private HttpClientInterface $httpClient, private ConradSettings $settings) + { + } + + public function getProviderInfo(): array + { + return [ + 'name' => 'Pollin', + 'description' => 'Retrieves part information from conrad.de', + 'url' => 'https://www.conrad.de/', + 'disabled_help' => 'Set API key in settings', + 'settings_class' => ConradSettings::class, + ]; + } + + public function getProviderKey(): string + { + return 'conrad'; + } + + public function isActive(): bool + { + return !empty($this->settings->apiKey); + } + + public function searchByKeyword(string $keyword): array + { + $url = self::SEARCH_ENDPOINT . '/' . $this->settings->country . '/' . $this->settings->language . '/' . $this->settings->customerType; + + $response = $this->httpClient->request('POST', $url, [ + 'query' => [ + 'apikey' => $this->settings->apiKey, + ], + 'json' => [ + 'query' => $keyword, + ], + ]); + + $out = []; + $results = $response->toArray(); + + foreach($results as $result) { + $out[] = new SearchResultDTO( + provider_key: $this->getProviderKey(), + provider_id: $result['productId'], + name: $result['title'], + description: '', + manufacturer: $result['brand']['name'] ?? null, + mpn: $result['manufacturerId'] ?? null, + preview_image_url: $result['image'] ?? null, + ); + } + + return $out; + } + + public function getDetails(string $id): PartDetailDTO + { + // TODO: Implement getDetails() method. + } + + public function getCapabilities(): array + { + return [ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::PRICE,]; + } +} diff --git a/src/Settings/InfoProviderSystem/ConradSettings.php b/src/Settings/InfoProviderSystem/ConradSettings.php new file mode 100644 index 00000000..2330e729 --- /dev/null +++ b/src/Settings/InfoProviderSystem/ConradSettings.php @@ -0,0 +1,69 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use App\Form\Type\APIKeyType; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\CountryType; +use Symfony\Component\Form\Extension\Core\Type\LanguageType; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.ips.conrad"))] +#[SettingsIcon("fa-plug")] +class ConradSettings +{ + use SettingsTrait; + + #[SettingsParameter(label: new TM("settings.ips.element14.apiKey"), + formType: APIKeyType::class, + formOptions: ["help_html" => true], envVar: "PROVIDER_CONRAD_API_KEY", envVarMode: EnvVarMode::OVERWRITE)] + public ?string $apiKey = null; + + #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: CountryType::class, + envVar: "PROVIDER_CONRAD_COUNTRY", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Country] + public string $country = "DE"; + + #[SettingsParameter(label: new TM("settings.ips.tme.language"), formType: LanguageType::class, + envVar: "PROVIDER_CONRAD_LANGUAGE", envVarMode: EnvVarMode::OVERWRITE)] + #[Assert\Language] + public string $language = "en"; + + #[SettingsParameter(label: new TM("settings.ips.conrad.customerType"), formType: ChoiceType::class, + formOptions: [ + "choices" => [ + "settings.ips.conrad.customerType.b2c" => "b2c", + "settings.ips.conrad.customerType.b2b" => "b2b", + ], + ], + envVar: "PROVIDER_CONRAD_LANGUAGE", envVarMode: EnvVarMode::OVERWRITE, )] + #[Assert\Choice(choices: ["b2c", "b2b"])] + public string $customerType = "b2c"; +} diff --git a/src/Settings/InfoProviderSystem/InfoProviderSettings.php b/src/Settings/InfoProviderSystem/InfoProviderSettings.php index d4679e23..fb31bdb9 100644 --- a/src/Settings/InfoProviderSystem/InfoProviderSettings.php +++ b/src/Settings/InfoProviderSystem/InfoProviderSettings.php @@ -63,7 +63,10 @@ class InfoProviderSettings #[EmbeddedSettings] public ?PollinSettings $pollin = null; - + #[EmbeddedSettings] public ?BuerklinSettings $buerklin = null; + + #[EmbeddedSettings] + public ?ConradSettings $conrad = null; } From 7ab33c859bf2ea642d33ffd03c16954c2e13c4b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 26 Jan 2026 23:07:01 +0100 Subject: [PATCH 02/78] Implemented basic functionality to search and retrieve part details --- .../Providers/ConradProvider.php | 65 +++++++- .../InfoProviderSystem/ConradSettings.php | 26 +-- .../InfoProviderSystem/ConradShopIDs.php | 156 ++++++++++++++++++ 3 files changed, 223 insertions(+), 24 deletions(-) create mode 100644 src/Settings/InfoProviderSystem/ConradShopIDs.php diff --git a/src/Services/InfoProviderSystem/Providers/ConradProvider.php b/src/Services/InfoProviderSystem/Providers/ConradProvider.php index b72be0bd..7212444b 100644 --- a/src/Services/InfoProviderSystem/Providers/ConradProvider.php +++ b/src/Services/InfoProviderSystem/Providers/ConradProvider.php @@ -31,7 +31,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; readonly class ConradProvider implements InfoProviderInterface { - private const SEARCH_ENDPOINT = 'https://api.conrad.de/search/1/v3/facetSearch'; + private const SEARCH_ENDPOINT = '/search/1/v3/facetSearch'; public function __construct(private HttpClientInterface $httpClient, private ConradSettings $settings) { @@ -40,7 +40,7 @@ readonly class ConradProvider implements InfoProviderInterface public function getProviderInfo(): array { return [ - 'name' => 'Pollin', + 'name' => 'Conrad', 'description' => 'Retrieves part information from conrad.de', 'url' => 'https://www.conrad.de/', 'disabled_help' => 'Set API key in settings', @@ -58,9 +58,38 @@ readonly class ConradProvider implements InfoProviderInterface return !empty($this->settings->apiKey); } + private function getProductUrl(string $productId): string + { + return 'https://' . $this->settings->shopID->getDomain() . '/' . $this->settings->shopID->getLanguage() . '/p/' . $productId; + } + + private function getFootprintFromTechnicalDetails(array $technicalDetails): ?string + { + foreach ($technicalDetails as $detail) { + if ($detail['name'] === 'ATT_LOV_HOUSING_SEMICONDUCTORS') { + return $detail['values'][0] ?? null; + } + } + + return null; + } + + private function getFootprintFromTechnicalAttributes(array $technicalDetails): ?string + { + foreach ($technicalDetails as $detail) { + if ($detail['attributeID'] === 'ATT.LOV.HOUSING_SEMICONDUCTORS') { + return $detail['values'][0]['value'] ?? null; + } + } + + return null; + } + public function searchByKeyword(string $keyword): array { - $url = self::SEARCH_ENDPOINT . '/' . $this->settings->country . '/' . $this->settings->language . '/' . $this->settings->customerType; + $url = $this->settings->shopID->getAPIRoot() . self::SEARCH_ENDPOINT . '/' + . $this->settings->shopID->getDomainEnd() . '/' . $this->settings->shopID->getLanguage() + . '/' . $this->settings->shopID->getCustomerType(); $response = $this->httpClient->request('POST', $url, [ 'query' => [ @@ -68,13 +97,15 @@ readonly class ConradProvider implements InfoProviderInterface ], 'json' => [ 'query' => $keyword, + 'size' => 25, ], ]); $out = []; $results = $response->toArray(); - foreach($results as $result) { + foreach($results['hits'] as $result) { + $out[] = new SearchResultDTO( provider_key: $this->getProviderKey(), provider_id: $result['productId'], @@ -83,6 +114,8 @@ readonly class ConradProvider implements InfoProviderInterface manufacturer: $result['brand']['name'] ?? null, mpn: $result['manufacturerId'] ?? null, preview_image_url: $result['image'] ?? null, + provider_url: $this->getProductUrl($result['productId']), + footprint: $this->getFootprintFromTechnicalDetails($result['technicalDetails'] ?? []), ); } @@ -91,7 +124,29 @@ readonly class ConradProvider implements InfoProviderInterface public function getDetails(string $id): PartDetailDTO { - // TODO: Implement getDetails() method. + $productInfoURL = $this->settings->shopID->getAPIRoot() . '/product/1/service/' . $this->settings->shopID->getShopID() + . '/product/' . $id; + + $response = $this->httpClient->request('GET', $productInfoURL, [ + 'query' => [ + 'apikey' => $this->settings->apiKey, + ] + ]); + + $data = $response->toArray(); + + return new PartDetailDTO( + provider_key: $this->getProviderKey(), + provider_id: $data['shortProductNumber'], + name: $data['productShortInformation']['title'], + description: $data['productShortInformation']['shortDescription'] ?? '', + manufacturer: $data['brand']['displayName'] ?? null, + mpn: $data['productFullInformation']['manufacturer']['id'] ?? null, + preview_image_url: $data['productShortInformation']['mainImage']['imageUrl'] ?? null, + provider_url: $this->getProductUrl($data['shortProductNumber']), + footprint: $this->getFootprintFromTechnicalAttributes($data['productFullInformation']['technicalAttributes'] ?? []), + notes: $data['productFullInformation']['description'] ?? null, + ); } public function getCapabilities(): array diff --git a/src/Settings/InfoProviderSystem/ConradSettings.php b/src/Settings/InfoProviderSystem/ConradSettings.php index 2330e729..999ebfe0 100644 --- a/src/Settings/InfoProviderSystem/ConradSettings.php +++ b/src/Settings/InfoProviderSystem/ConradSettings.php @@ -31,6 +31,7 @@ use Jbtronics\SettingsBundle\Settings\SettingsParameter; use Jbtronics\SettingsBundle\Settings\SettingsTrait; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\CountryType; +use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\LanguageType; use Symfony\Component\Translation\TranslatableMessage as TM; use Symfony\Component\Validator\Constraints as Assert; @@ -46,24 +47,11 @@ class ConradSettings formOptions: ["help_html" => true], envVar: "PROVIDER_CONRAD_API_KEY", envVarMode: EnvVarMode::OVERWRITE)] public ?string $apiKey = null; - #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: CountryType::class, - envVar: "PROVIDER_CONRAD_COUNTRY", envVarMode: EnvVarMode::OVERWRITE)] - #[Assert\Country] - public string $country = "DE"; + #[SettingsParameter(label: new TM("settings.ips.conrad.shopID"), + formType: EnumType::class, + formOptions: ['class' => ConradShopIDs::class], + )] + public ConradShopIDs $shopID = ConradShopIDs::COM_B2B; - #[SettingsParameter(label: new TM("settings.ips.tme.language"), formType: LanguageType::class, - envVar: "PROVIDER_CONRAD_LANGUAGE", envVarMode: EnvVarMode::OVERWRITE)] - #[Assert\Language] - public string $language = "en"; - - #[SettingsParameter(label: new TM("settings.ips.conrad.customerType"), formType: ChoiceType::class, - formOptions: [ - "choices" => [ - "settings.ips.conrad.customerType.b2c" => "b2c", - "settings.ips.conrad.customerType.b2b" => "b2b", - ], - ], - envVar: "PROVIDER_CONRAD_LANGUAGE", envVarMode: EnvVarMode::OVERWRITE, )] - #[Assert\Choice(choices: ["b2c", "b2b"])] - public string $customerType = "b2c"; + public bool $includeVAT = true; } diff --git a/src/Settings/InfoProviderSystem/ConradShopIDs.php b/src/Settings/InfoProviderSystem/ConradShopIDs.php new file mode 100644 index 00000000..2d8710e7 --- /dev/null +++ b/src/Settings/InfoProviderSystem/ConradShopIDs.php @@ -0,0 +1,156 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +enum ConradShopIDs: string implements TranslatableInterface +{ + case COM_B2B = 'HP_COM_B2B'; + case DE_B2B = 'CQ_DE_B2B'; + case AT_B2C = 'CQ_AT_B2C'; + case CH_B2C = 'CQ_CH_B2C'; + case SE_B2B = 'HP_SE_B2B'; + case HU_B2C = 'CQ_HU_B2C'; + case CZ_B2B = 'HP_CZ_B2B'; + case SI_B2B = 'HP_SI_B2B'; + case SK_B2B = 'HP_SK_B2B'; + case BE_B2B = 'HP_BE_B2B'; + case DE_B2C = 'CQ_DE_B2C'; + case PL_B2B = 'HP_PL_B2B'; + case NL_B2B = 'CQ_NL_B2B'; + case DK_B2B = 'HP_DK_B2B'; + case IT_B2B = 'HP_IT_B2B'; + case NL_B2C = 'CQ_NL_B2C'; + case FR_B2B = 'HP_FR_B2B'; + case AT_B2B = 'CQ_AT_B2B'; + case HR_B2B = 'HP_HR_B2B'; + + + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + return match ($this) { + self::DE_B2B => "conrad.de (B2B)", + self::AT_B2C => "conrad.at (B2C)", + self::CH_B2C => "conrad.ch (B2C)", + self::SE_B2B => "conrad.se (B2B)", + self::HU_B2C => "conrad.hu (B2C)", + self::CZ_B2B => "conrad.cz (B2B)", + self::SI_B2B => "conrad.si (B2B)", + self::SK_B2B => "conrad.sk (B2B)", + self::BE_B2B => "conrad.be (B2B)", + self::DE_B2C => "conrad.de (B2C)", + self::PL_B2B => "conrad.pl (B2B)", + self::NL_B2B => "conrad.nl (B2B)", + self::DK_B2B => "conrad.dk (B2B)", + self::IT_B2B => "conrad.it (B2B)", + self::NL_B2C => "conrad.nl (B2C)", + self::FR_B2B => "conrad.fr (B2B)", + self::COM_B2B => "conrad.com (B2B)", + self::AT_B2B => "conrad.at (B2B)", + self::HR_B2B => "conrad.hr (B2B)", + }; + } + + public function getDomain(): string + { + return 'conrad.' . $this->getDomainEnd(); + } + + /** + * Retrieves the API root URL for this shop ID. e.g. https://api.conrad.de + * @return string + */ + public function getAPIRoot(): string + { + return 'https://api.' . $this->getDomain(); + } + + /** + * Returns the shop ID value used in the API requests. e.g. 'CQ_DE_B2B' + * @return string + */ + public function getShopID(): string + { + return $this->value; + } + + public function getDomainEnd(): string + { + return match ($this) { + self::DE_B2B, self::DE_B2C => 'de', + self::AT_B2B, self::AT_B2C => 'at', + self::CH_B2C => 'ch', + self::SE_B2B => 'se', + self::HU_B2C => 'hu', + self::CZ_B2B => 'cz', + self::SI_B2B => 'si', + self::SK_B2B => 'sk', + self::BE_B2B => 'be', + self::PL_B2B => 'pl', + self::NL_B2B, self::NL_B2C => 'nl', + self::DK_B2B => 'dk', + self::IT_B2B => 'it', + self::FR_B2B => 'fr', + self::COM_B2B => 'com', + self::HR_B2B => 'hr', + }; + } + + public function getLanguage(): string + { + return match ($this) { + self::DE_B2B, self::DE_B2C, self::AT_B2B, self::AT_B2C => 'de', + self::CH_B2C => 'de', + self::SE_B2B => 'sv', + self::HU_B2C => 'hu', + self::CZ_B2B => 'cs', + self::SI_B2B => 'sl', + self::SK_B2B => 'sk', + self::BE_B2B => 'nl', + self::PL_B2B => 'pl', + self::NL_B2B, self::NL_B2C => 'nl', + self::DK_B2B => 'da', + self::IT_B2B => 'it', + self::FR_B2B => 'fr', + self::COM_B2B => 'en', + self::HR_B2B => 'hr', + }; + } + + /** + * Retrieves the customer type for this shop ID. e.g. 'b2b' or 'b2c' + * @return string 'b2b' or 'b2c' + */ + public function getCustomerType(): string + { + return match ($this) { + self::DE_B2B, self::AT_B2B, self::SE_B2B, self::CZ_B2B, self::SI_B2B, + self::SK_B2B, self::BE_B2B, self::PL_B2B, self::NL_B2B, self::DK_B2B, + self::IT_B2B, self::FR_B2B, self::COM_B2B, self::HR_B2B => 'b2b', + self::DE_B2C, self::AT_B2C, self::CH_B2C, self::HU_B2C, self::NL_B2C => 'b2c', + }; + } +} From 3ed62f5cee80ca4dbfa935b27caff50b9af0af1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 26 Jan 2026 23:18:32 +0100 Subject: [PATCH 03/78] Allow to retrieve parameters from conrad --- .../Providers/ConradProvider.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Services/InfoProviderSystem/Providers/ConradProvider.php b/src/Services/InfoProviderSystem/Providers/ConradProvider.php index 7212444b..8c343099 100644 --- a/src/Services/InfoProviderSystem/Providers/ConradProvider.php +++ b/src/Services/InfoProviderSystem/Providers/ConradProvider.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; +use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Settings\InfoProviderSystem\ConradSettings; @@ -85,6 +86,20 @@ readonly class ConradProvider implements InfoProviderInterface return null; } + private function technicalAttributesToParameters(array $technicalAttributes): array + { + $parameters = []; + foreach ($technicalAttributes as $attribute) { + if ($attribute['multiValue'] ?? false === true) { + throw new \LogicException('Multi value attributes are not supported yet'); + } + $parameters[] = ParameterDTO::parseValueField($attribute['attributeName'], + $attribute['values'][0]['value'], $attribute['values'][0]['unit']['name'] ?? null); + } + + return $parameters; + } + public function searchByKeyword(string $keyword): array { $url = $this->settings->shopID->getAPIRoot() . self::SEARCH_ENDPOINT . '/' @@ -146,6 +161,7 @@ readonly class ConradProvider implements InfoProviderInterface provider_url: $this->getProductUrl($data['shortProductNumber']), footprint: $this->getFootprintFromTechnicalAttributes($data['productFullInformation']['technicalAttributes'] ?? []), notes: $data['productFullInformation']['description'] ?? null, + parameters: $this->technicalAttributesToParameters($data['productFullInformation']['technicalAttributes'] ?? []), ); } From 42fe781ef884b627a498a57f633130fb10ac9dfd Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:36:33 +0100 Subject: [PATCH 04/78] Add Update Manager for automated Part-DB updates This feature adds a comprehensive Update Manager similar to Mainsail's update system, allowing administrators to update Part-DB directly from the web interface. Features: - Web UI at /admin/update-manager showing current and available versions - Support for Git-based installations with automatic update execution - Maintenance mode during updates to prevent user access - Automatic database backup before updates - Git rollback points for recovery (tags created before each update) - Progress tracking with real-time status updates - Update history and log viewing - Downgrade support with appropriate UI messaging - CLI command `php bin/console partdb:update` for server-side updates New files: - UpdateManagerController: Handles all web UI routes - UpdateCommand: CLI command for running updates - UpdateExecutor: Core update execution logic with safety mechanisms - UpdateChecker: GitHub API integration for version checking - InstallationTypeDetector: Detects installation type (Git/Docker/ZIP) - MaintenanceModeSubscriber: Blocks user access during maintenance - UpdateExtension: Twig functions for update notifications UI improvements: - Update notification in navbar for admins when update available - Confirmation dialogs for update/downgrade actions - Downgrade-specific text throughout the interface - Progress page with auto-refresh --- config/permissions.yaml | 4 + src/Command/UpdateCommand.php | 446 ++++++++++ src/Controller/UpdateManagerController.php | 268 ++++++ .../MaintenanceModeSubscriber.php | 231 +++++ .../System/InstallationTypeDetector.php | 224 +++++ src/Services/System/UpdateChecker.php | 349 ++++++++ src/Services/System/UpdateExecutor.php | 832 ++++++++++++++++++ src/Services/Trees/ToolsTreeBuilder.php | 7 + src/Twig/UpdateExtension.php | 79 ++ templates/_navbar.html.twig | 13 + .../admin/update_manager/index.html.twig | 374 ++++++++ .../admin/update_manager/log_viewer.html.twig | 40 + .../admin/update_manager/progress.html.twig | 196 +++++ .../update_manager/release_notes.html.twig | 110 +++ templates/maintenance/maintenance.html.twig | 251 ++++++ translations/messages.en.xlf | 702 +++++++++++++++ 16 files changed, 4126 insertions(+) create mode 100644 src/Command/UpdateCommand.php create mode 100644 src/Controller/UpdateManagerController.php create mode 100644 src/EventSubscriber/MaintenanceModeSubscriber.php create mode 100644 src/Services/System/InstallationTypeDetector.php create mode 100644 src/Services/System/UpdateChecker.php create mode 100644 src/Services/System/UpdateExecutor.php create mode 100644 src/Twig/UpdateExtension.php create mode 100644 templates/admin/update_manager/index.html.twig create mode 100644 templates/admin/update_manager/log_viewer.html.twig create mode 100644 templates/admin/update_manager/progress.html.twig create mode 100644 templates/admin/update_manager/release_notes.html.twig create mode 100644 templates/maintenance/maintenance.html.twig diff --git a/config/permissions.yaml b/config/permissions.yaml index 8c6a145e..0dabf9d3 100644 --- a/config/permissions.yaml +++ b/config/permissions.yaml @@ -297,6 +297,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co show_updates: label: "perm.system.show_available_updates" apiTokenRole: ROLE_API_ADMIN + manage_updates: + label: "perm.system.manage_updates" + alsoSet: ['show_updates', 'server_infos'] + apiTokenRole: ROLE_API_ADMIN attachments: diff --git a/src/Command/UpdateCommand.php b/src/Command/UpdateCommand.php new file mode 100644 index 00000000..4f2cae86 --- /dev/null +++ b/src/Command/UpdateCommand.php @@ -0,0 +1,446 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Command; + +use App\Services\System\InstallationType; +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->refreshGitInfo(); + $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->getLatestRelease($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->isNewerVersion($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/Controller/UpdateManagerController.php b/src/Controller/UpdateManagerController.php new file mode 100644 index 00000000..10a719de --- /dev/null +++ b/src/Controller/UpdateManagerController.php @@ -0,0 +1,268 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Controller; + +use App\Services\System\UpdateChecker; +use App\Services\System\UpdateExecutor; +use Shivas\VersioningBundle\Service\VersionManagerInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +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('/admin/update-manager')] +class UpdateManagerController extends AbstractController +{ + public function __construct(private readonly UpdateChecker $updateChecker, + private readonly UpdateExecutor $updateExecutor, + private readonly VersionManagerInterface $versionManager) + { + + } + + /** + * 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->updateExecutor->getBackups(), + ]); + } + + /** + * 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->refreshGitInfo(); + + 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.show_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'); + + // 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->getLatestRelease(); + 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.show_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(), + ]); + } +} diff --git a/src/EventSubscriber/MaintenanceModeSubscriber.php b/src/EventSubscriber/MaintenanceModeSubscriber.php new file mode 100644 index 00000000..60623b45 --- /dev/null +++ b/src/EventSubscriber/MaintenanceModeSubscriber.php @@ -0,0 +1,231 @@ +. + */ + +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\RequestEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Twig\Environment; + +/** + * Blocks all web requests when maintenance mode is enabled during updates. + */ +class MaintenanceModeSubscriber implements EventSubscriberInterface +{ + public function __construct(private readonly UpdateExecutor $updateExecutor, + private readonly Environment $twig) + { + + } + + public static function getSubscribedEvents(): array + { + return [ + // High priority to run before other listeners + KernelEvents::REQUEST => ['onKernelRequest', 512], + ]; + } + + 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 CLI requests + if (php_sapi_name() === 'cli') { + return; + } + + // Get maintenance info + $maintenanceInfo = $this->updateExecutor->getMaintenanceInfo(); + $lockInfo = $this->updateExecutor->getLockInfo(); + + // Calculate how long the update has been running + $duration = null; + if ($lockInfo && isset($lockInfo['started_at'])) { + try { + $startedAt = new \DateTime($lockInfo['started_at']); + $now = new \DateTime(); + $duration = $now->getTimestamp() - $startedAt->getTimestamp(); + } catch (\Exception) { + // Ignore date parsing errors + } + } + + // Try to render the Twig template, fall back to simple HTML + try { + $content = $this->twig->render('maintenance/maintenance.html.twig', [ + 'reason' => $maintenanceInfo['reason'] ?? 'Maintenance in progress', + 'started_at' => $maintenanceInfo['enabled_at'] ?? null, + 'duration' => $duration, + ]); + } catch (\Exception) { + // Fallback to simple HTML if Twig fails + $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'; + + return << + + + + + + Part-DB - Maintenance + + + +
+
+ ⚙️ +
+

Part-DB is Updating

+

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

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

+ Update running for {$durationText}
+ This page will automatically refresh every 15 seconds. +

+
+ + +HTML; + } +} diff --git a/src/Services/System/InstallationTypeDetector.php b/src/Services/System/InstallationTypeDetector.php new file mode 100644 index 00000000..0cd99a04 --- /dev/null +++ b/src/Services/System/InstallationTypeDetector.php @@ -0,0 +1,224 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\System; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Process\Process; + +/** + * 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', + self::UNKNOWN => 'Unknown', + }; + } + + public function supportsAutoUpdate(): bool + { + return match($this) { + self::GIT => true, + self::DOCKER => false, + self::ZIP_RELEASE => true, + 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, extract it, and run migrations.', + self::UNKNOWN => 'Unable to determine installation type. Please update manually.', + }; + } +} + +class InstallationTypeDetector +{ + public function __construct(#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir) + { + + } + + /** + * 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 is_dir($this->project_dir . '/.git'); + } + + /** + * 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. + */ + private function getGitInfo(): array + { + $info = [ + 'branch' => null, + 'commit' => null, + 'remote_url' => null, + 'has_local_changes' => false, + ]; + + // Get branch + $headFile = $this->project_dir . '/.git/HEAD'; + if (file_exists($headFile)) { + $head = file_get_contents($headFile); + if (preg_match('#ref: refs/heads/(.+)#', $head, $matches)) { + $info['branch'] = trim($matches[1]); + } + } + + // Get remote URL + $configFile = $this->project_dir . '/.git/config'; + if (file_exists($configFile)) { + $config = file_get_contents($configFile); + if (preg_match('#url = (.+)#', $config, $matches)) { + $info['remote_url'] = trim($matches[1]); + } + } + + // Get commit hash + $process = new Process(['git', 'rev-parse', '--short', 'HEAD'], $this->project_dir); + $process->run(); + if ($process->isSuccessful()) { + $info['commit'] = trim($process->getOutput()); + } + + // Check for local changes + $process = new Process(['git', 'status', '--porcelain'], $this->project_dir); + $process->run(); + $info['has_local_changes'] = !empty(trim($process->getOutput())); + + return $info; + } + + /** + * Get Docker-specific information. + */ + 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/UpdateChecker.php b/src/Services/System/UpdateChecker.php new file mode 100644 index 00000000..a881f614 --- /dev/null +++ b/src/Services/System/UpdateChecker.php @@ -0,0 +1,349 @@ +. + */ + +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, + #[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. + */ + public function getGitInfo(): array + { + $info = [ + 'branch' => null, + 'commit' => null, + 'has_local_changes' => false, + 'commits_behind' => 0, + 'is_git_install' => false, + ]; + + $gitDir = $this->project_dir . '/.git'; + + if (!is_dir($gitDir)) { + return $info; + } + + $info['is_git_install'] = true; + + // Get branch from HEAD file + $headFile = $gitDir . '/HEAD'; + if (file_exists($headFile)) { + $head = file_get_contents($headFile); + if (preg_match('#ref: refs/heads/(.+)#', $head, $matches)) { + $info['branch'] = trim($matches[1]); + } + } + + // Get current commit + $process = new Process(['git', 'rev-parse', '--short', 'HEAD'], $this->project_dir); + $process->run(); + if ($process->isSuccessful()) { + $info['commit'] = trim($process->getOutput()); + } + + // Check for local changes + $process = new Process(['git', 'status', '--porcelain'], $this->project_dir); + $process->run(); + $info['has_local_changes'] = !empty(trim($process->getOutput())); + + // 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 . '_' . md5($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 refreshGitInfo(): void + { + $gitInfo = $this->getGitInfo(); + if ($gitInfo['branch']) { + $this->updateCache->delete(self::CACHE_KEY_COMMITS . '_' . md5($gitInfo['branch'])); + } + $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. + */ + public function getLatestRelease(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 isNewerVersion(string $version): bool + { + try { + $targetVersion = Version::fromString(ltrim($version, 'v')); + return $targetVersion->isGreaterThan($this->getCurrentVersion()); + } catch (\Exception) { + return false; + } + } + + /** + * Get comprehensive update status. + */ + public function getUpdateStatus(): array + { + $current = $this->getCurrentVersion(); + $latest = $this->getLatestRelease(); + $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. + */ + public function getAvailableUpdates(bool $includePrerelease = false): array + { + $releases = $this->getAvailableReleases(); + $current = $this->getCurrentVersion(); + $updates = []; + + foreach ($releases as $release) { + if ($release['draft']) { + continue; + } + + if (!$includePrerelease && $release['prerelease']) { + continue; + } + + try { + $releaseVersion = Version::fromString($release['version']); + if ($releaseVersion->isGreaterThan($current)) { + $updates[] = $release; + } + } catch (\Exception) { + continue; + } + } + + return $updates; + } +} diff --git a/src/Services/System/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php new file mode 100644 index 00000000..7bc997f7 --- /dev/null +++ b/src/Services/System/UpdateExecutor.php @@ -0,0 +1,832 @@ +. + */ + +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. + */ +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 BACKUP_DIR = 'var/backups'; + 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 VersionManagerInterface $versionManager) + { + + } + + /** + * Get the current version string for use in filenames. + */ + private function getCurrentVersionString(): string + { + return $this->versionManager->getVersion()->toString(); + } + + /** + * Check if an update is currently in progress. + */ + public function isLocked(): bool + { + $lockFile = $this->project_dir . '/' . self::LOCK_FILE; + + if (!file_exists($lockFile)) { + return false; + } + + // Check if lock is stale (older than 1 hour) + $lockData = json_decode(file_get_contents($lockFile), true); + 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. + */ + 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); + } + + /** + * Check if maintenance mode is enabled. + */ + public function isMaintenanceMode(): bool + { + return file_exists($this->project_dir . '/' . self::MAINTENANCE_FILE); + } + + /** + * Get maintenance mode information. + */ + 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); + } + + /** + * 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_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_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 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->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 dependencies + $stepStart = microtime(true); + $this->runCommand([ + 'composer', 'install', + '--no-dev', + '--optimize-autoloader', + '--no-interaction', + '--no-progress', + ], 'Install dependencies', 600); + $log('composer', 'Installed/updated dependencies', true, microtime(true) - $stepStart); + + // Step 8: 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 9: 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 10: 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 11: Disable maintenance mode + $stepStart = microtime(true); + $this->disableMaintenanceMode(); + $log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart); + + // Step 12: 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 dependencies 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, + ]; + } + } + + /** + * Create a backup before updating. + */ + private function createBackup(string $targetVersion): string + { + $backupDir = $this->project_dir . '/' . self::BACKUP_DIR; + + if (!is_dir($backupDir)) { + $this->filesystem->mkdir($backupDir, 0755); + } + + // Include version numbers in backup filename: pre-update-v2.5.1-to-v2.6.0-2024-01-30-185400.zip + $currentVersion = $this->getCurrentVersionString(); + $targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion); + $backupFile = $backupDir . '/pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.zip'; + + $this->runCommand([ + 'php', 'bin/console', 'partdb:backup', + '--full', + '--overwrite', + $backupFile, + ], 'Create backup', 600); + + return $backupFile; + } + + /** + * Run a shell command with proper error handling. + */ + private 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, + '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; + } + + /** + * 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. + */ + 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, fn($a, $b) => $b['date'] <=> $a['date']); + + return $logs; + } + + /** + * Get list of backups. + */ + public function getBackups(): array + { + $backupDir = $this->project_dir . '/' . self::BACKUP_DIR; + + 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, fn($a, $b) => $b['date'] <=> $a['date']); + + return $backups; + } + + /** + * 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. + */ + public 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_PRETTY_PRINT)); + } + + /** + * Get current update progress from file. + */ + public function getProgress(): ?array + { + $progressFile = $this->getProgressFilePath(); + + if (!file_exists($progressFile)) { + return null; + } + + $data = json_decode(file_get_contents($progressFile), true); + + // 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' => 12, + '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); + } + + // 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 + $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 37a09b09..1bb81bf7 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -315,6 +315,13 @@ class ToolsTreeBuilder ))->setIcon('fa fa-fw fa-gears fa-solid'); } + if ($this->security->isGranted('@system.show_updates')) { + $nodes[] = (new TreeViewNode( + $this->translator->trans('tree.tools.system.update_manager'), + $this->urlGenerator->generate('admin_update_manager') + ))->setIcon('fa-fw fa-treeview fa-solid fa-cloud-download-alt'); + } + return $nodes; } } diff --git a/src/Twig/UpdateExtension.php b/src/Twig/UpdateExtension.php new file mode 100644 index 00000000..10264d12 --- /dev/null +++ b/src/Twig/UpdateExtension.php @@ -0,0 +1,79 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Twig; + +use App\Services\System\UpdateAvailableManager; +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 UpdateAvailableManager $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 446ccdab..30562ec4 100644 --- a/templates/_navbar.html.twig +++ b/templates/_navbar.html.twig @@ -74,6 +74,19 @@ - \ No newline at end of file + diff --git a/templates/info_providers/from_url/from_url.html.twig b/templates/info_providers/from_url/from_url.html.twig new file mode 100644 index 00000000..5aad1a03 --- /dev/null +++ b/templates/info_providers/from_url/from_url.html.twig @@ -0,0 +1,21 @@ +{% extends "main_card.html.twig" %} + +{% import "info_providers/providers.macro.html.twig" as providers_macro %} +{% import "helper.twig" as helper %} + +{% block title %} + {% trans %}info_providers.from_url.title{% endtrans %} +{% endblock %} + +{% block card_title %} + {% trans %}info_providers.from_url.title{% endtrans %} +{% endblock %} + +{% block card_content %} +

{% trans %}info_providers.from_url.help{% endtrans %}

+ + {{ form_start(form) }} + {{ form_row(form.url) }} + {{ form_row(form.submit) }} + {{ form_end(form) }} +{% endblock %} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 706d629a..87f6c2f6 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -14334,5 +14334,29 @@ Buerklin-API Authentication server: When the provider is enabled, users can make requests to arbitary websites on behalf of the Part-DB server. Only enable this, if you are aware of the potential consequences. + + + info_providers.from_url.title + Create [part] from URL + + + + + info_providers.from_url.url.label + URL + + + + + info_providers.from_url.no_part_found + No part found from the given URL. Are you sure this is a valid shop URL? + + + + + info_providers.from_url.help + Creates a part based on the given URL. It tries to delegate it to an existing info provider if possible, otherwise it will be tried to extract rudimentary data from the webpage's metadata. + + From 47c7ee9f07131ecd2b6be11eca8f6e42abc974f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 1 Feb 2026 18:24:46 +0100 Subject: [PATCH 38/78] Allow to extract parameters form additionalProperty JSONLD data --- .../Providers/GenericWebProvider.php | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php index d05aac8f..4b73ad6e 100644 --- a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php +++ b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; use App\Exceptions\ProviderIDNotSupportedException; +use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; @@ -75,9 +76,9 @@ class GenericWebProvider implements InfoProviderInterface public function searchByKeyword(string $keyword): array { try { - return [ - $this->getDetails($keyword) - ]; } catch (ProviderIDNotSupportedException $e) { + return [ + $this->getDetails($keyword) + ]; } catch (ProviderIDNotSupportedException $e) { return []; } } @@ -143,7 +144,7 @@ class GenericWebProvider implements InfoProviderInterface order_number: (string) ($jsonLd['sku'] ?? $jsonLd['@id'] ?? $jsonLd['gtin'] ?? 'Unknown'), prices: $prices, product_url: $jsonLd['url'] ?? $url, - )]; + )]; } $image = null; @@ -161,6 +162,26 @@ class GenericWebProvider implements InfoProviderInterface $image = $image['contentUrl'] ?? $image['url'] ?? null; } + //Try to extract parameters from additionalProperty + $parameters = []; + 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'] ?? 'Unknown', + value: $property['value'] ?? '', + unit: $property['unitText'] + ); + } else { + $parameters[] = ParameterDTO::parseValueIncludingUnit( + name: $property['name'] ?? 'Unknown', + value: $property['value'] ?? '' + ); + } + } + } + + return new PartDetailDTO( provider_key: $this->getProviderKey(), provider_id: $url, @@ -169,9 +190,10 @@ class GenericWebProvider implements InfoProviderInterface 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, + preview_image_url: $image, provider_url: $url, notes: $notes, + parameters: $parameters, vendor_infos: $vendor_infos, mass: isset($jsonLd['weight']['value']) ? (float)$jsonLd['weight']['value'] : null, ); From 10c192edd1697929e50bb52cfb83a50263c341dd Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:17:22 +0100 Subject: [PATCH 39/78] Address PR feedback: add yarn build, env vars, and BackupManager Changes based on maintainer feedback from PR #1217: 1. Add yarn install/build steps to update process - Added yarn availability check in validateUpdatePreconditions - Added yarn install and yarn build steps after composer install - Added yarn rebuild to rollback process - Updated total steps count from 12 to 14 2. Add environment variables to disable web features - DISABLE_WEB_UPDATES: Completely disable web-based updates - DISABLE_BACKUP_RESTORE: Disable backup restore from web UI - Added checks in controller and template 3. Extract BackupManager service - New service handles backup creation, listing, details, and restoration - UpdateExecutor now delegates backup operations to BackupManager - Cleaner separation of concerns for future reuse 4. Merge upstream/master and resolve translation conflicts - Added Conrad info provider and generic web provider translations - Kept Update Manager translations --- .env | 11 + src/Controller/UpdateManagerController.php | 36 +- src/Services/System/BackupManager.php | 487 ++++++++++++++++++ src/Services/System/UpdateExecutor.php | 371 +++---------- .../admin/update_manager/index.html.twig | 23 +- translations/messages.en.xlf | 18 + 6 files changed, 653 insertions(+), 293 deletions(-) create mode 100644 src/Services/System/BackupManager.php diff --git a/.env b/.env index 9a6ce846..3196241b 100644 --- a/.env +++ b/.env @@ -59,6 +59,17 @@ ERROR_PAGE_ADMIN_EMAIL='' # If this is set to true, solutions to common problems are shown on error pages. Disable this, if you do not want your users to see them... ERROR_PAGE_SHOW_HELP=1 +################################################################################### +# Update Manager settings +################################################################################### + +# Set this to 1 to completely disable web-based updates, regardless of user permissions. +# Use this if you prefer to manage updates through your own deployment process. +DISABLE_WEB_UPDATES=0 + +# Set this to 1 to disable the backup restore feature from the web UI. +# Restoring backups is a destructive operation that could cause data loss. +DISABLE_BACKUP_RESTORE=0 ################################################################################### # SAML Single sign on-settings diff --git a/src/Controller/UpdateManagerController.php b/src/Controller/UpdateManagerController.php index 8455516a..b247cb38 100644 --- a/src/Controller/UpdateManagerController.php +++ b/src/Controller/UpdateManagerController.php @@ -27,9 +27,11 @@ 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; /** @@ -41,11 +43,35 @@ use Symfony\Component\Routing\Attribute\Route; #[Route('/admin/update-manager')] class UpdateManagerController extends AbstractController { - public function __construct(private readonly UpdateChecker $updateChecker, + public function __construct( + private readonly UpdateChecker $updateChecker, private readonly UpdateExecutor $updateExecutor, - private readonly VersionManagerInterface $versionManager) - { + private readonly VersionManagerInterface $versionManager, + #[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.'); + } } /** @@ -71,6 +97,8 @@ class UpdateManagerController extends AbstractController 'maintenance_info' => $this->updateExecutor->getMaintenanceInfo(), 'update_logs' => $this->updateExecutor->getUpdateLogs(), 'backups' => $this->updateExecutor->getBackups(), + 'web_updates_disabled' => $this->webUpdatesDisabled, + 'backup_restore_disabled' => $this->backupRestoreDisabled, ]); } @@ -177,6 +205,7 @@ class UpdateManagerController extends AbstractController 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'))) { @@ -290,6 +319,7 @@ class UpdateManagerController extends AbstractController 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'))) { diff --git a/src/Services/System/BackupManager.php b/src/Services/System/BackupManager.php new file mode 100644 index 00000000..b646e433 --- /dev/null +++ b/src/Services/System/BackupManager.php @@ -0,0 +1,487 @@ +. + */ + +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. + */ +class BackupManager +{ + private const BACKUP_DIR = 'var/backups'; + + public function __construct( + #[Autowire(param: 'kernel.project_dir')] + private readonly string $projectDir, + private readonly LoggerInterface $logger, + private readonly Filesystem $filesystem, + private readonly VersionManagerInterface $versionManager, + private readonly EntityManagerInterface $entityManager, + ) { + } + + /** + * 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->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. + * + * @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, fn($a, $b) => $b['date'] <=> $a['date']); + + return $backups; + } + + /** + * Get details about a specific backup file. + * + * @param string $filename The backup filename + * @return array|null 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]); + } + } + + /** + * Run a shell command with proper error handling. + */ + private function runCommand(array $command, string $description, int $timeout = 120): string + { + $process = new Process($command, $this->projectDir); + $process->setTimeout($timeout); + + // Set environment variables + $currentEnv = getenv(); + if (!is_array($currentEnv)) { + $currentEnv = []; + } + $env = array_merge($currentEnv, [ + 'HOME' => $this->projectDir, + 'COMPOSER_HOME' => $this->projectDir . '/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/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php index 837cde4c..84113981 100644 --- a/src/Services/System/UpdateExecutor.php +++ b/src/Services/System/UpdateExecutor.php @@ -23,7 +23,6 @@ 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; @@ -41,7 +40,6 @@ 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 BACKUP_DIR = 'var/backups'; private const PROGRESS_FILE = 'var/update_progress.json'; /** @var array */ @@ -49,13 +47,15 @@ class UpdateExecutor 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, + 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 VersionManagerInterface $versionManager, - private readonly EntityManagerInterface $entityManager) - { - + private readonly BackupManager $backupManager, + ) { } /** @@ -252,6 +252,13 @@ class UpdateExecutor $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) { @@ -345,7 +352,7 @@ class UpdateExecutor // Step 4: Create backup (optional) if ($createBackup) { $stepStart = microtime(true); - $backupFile = $this->createBackup($targetVersion); + $backupFile = $this->backupManager->createBackup($targetVersion); $log('backup', 'Created backup: ' . basename($backupFile), true, microtime(true) - $stepStart); } @@ -359,7 +366,7 @@ class UpdateExecutor $this->runCommand(['git', 'checkout', $targetVersion], 'Checkout version'); $log('checkout', 'Checked out version: ' . $targetVersion, true, microtime(true) - $stepStart); - // Step 7: Install dependencies + // Step 7: Install PHP dependencies $stepStart = microtime(true); $this->runCommand([ 'composer', 'install', @@ -367,10 +374,26 @@ class UpdateExecutor '--optimize-autoloader', '--no-interaction', '--no-progress', - ], 'Install dependencies', 600); - $log('composer', 'Installed/updated dependencies', true, microtime(true) - $stepStart); + ], 'Install PHP dependencies', 600); + $log('composer', 'Installed/updated PHP dependencies', true, microtime(true) - $stepStart); - // Step 8: Run database migrations + // 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', @@ -379,7 +402,7 @@ class UpdateExecutor ], 'Run migrations', 300); $log('migrations', 'Database migrations completed', true, microtime(true) - $stepStart); - // Step 9: Clear cache + // Step 11: Clear cache $stepStart = microtime(true); $this->runCommand([ 'php', 'bin/console', 'cache:clear', @@ -388,7 +411,7 @@ class UpdateExecutor ], 'Clear cache', 120); $log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart); - // Step 10: Warm up cache + // Step 12: Warm up cache $stepStart = microtime(true); $this->runCommand([ 'php', 'bin/console', 'cache:warmup', @@ -396,12 +419,12 @@ class UpdateExecutor ], 'Warmup cache', 120); $log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart); - // Step 11: Disable maintenance mode + // Step 13: Disable maintenance mode $stepStart = microtime(true); $this->disableMaintenanceMode(); $log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart); - // Step 12: Release lock + // Step 14: Release lock $stepStart = microtime(true); $this->releaseLock(); @@ -433,7 +456,21 @@ class UpdateExecutor '--optimize-autoloader', '--no-interaction', ], 'Reinstall dependencies after rollback', 600); - $log('rollback_composer', 'Reinstalled dependencies after rollback', true); + $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([ @@ -462,32 +499,6 @@ class UpdateExecutor } } - /** - * Create a backup before updating. - */ - private function createBackup(string $targetVersion): string - { - $backupDir = $this->project_dir . '/' . self::BACKUP_DIR; - - if (!is_dir($backupDir)) { - $this->filesystem->mkdir($backupDir, 0755); - } - - // Include version numbers in backup filename: pre-update-v2.5.1-to-v2.6.0-2024-01-30-185400.zip - $currentVersion = $this->getCurrentVersionString(); - $targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion); - $backupFile = $backupDir . '/pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.zip'; - - $this->runCommand([ - 'php', 'bin/console', 'partdb:backup', - '--full', - '--overwrite', - $backupFile, - ], 'Create backup', 600); - - return $backupFile; - } - /** * Run a shell command with proper error handling. */ @@ -605,79 +616,27 @@ class UpdateExecutor /** * Get list of backups. + * @deprecated Use BackupManager::getBackups() directly */ public function getBackups(): array { - $backupDir = $this->project_dir . '/' . self::BACKUP_DIR; - - 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, fn($a, $b) => $b['date'] <=> $a['date']); - - return $backups; + return $this->backupManager->getBackups(); } /** * Get details about a specific backup file. - * - * @param string $filename The backup filename - * @return array|null Backup details or null if not found + * @deprecated Use BackupManager::getBackupDetails() directly */ public function getBackupDetails(string $filename): ?array { - $backupDir = $this->project_dir . '/' . self::BACKUP_DIR; - $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; + return $this->backupManager->getBackupDetails($filename); } /** - * Restore from a backup file. + * 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 @@ -713,18 +672,12 @@ class UpdateExecutor }; try { - // Validate backup file - $backupDir = $this->project_dir . '/' . self::BACKUP_DIR; - $backupPath = $backupDir . '/' . basename($filename); - - if (!file_exists($backupPath)) { - throw new \RuntimeException('Backup file not found: ' . $filename); - } - $stepStart = microtime(true); // Step 1: Acquire lock - $this->acquireLock('restore'); + 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 @@ -732,65 +685,43 @@ class UpdateExecutor $this->enableMaintenanceMode('Restoring from backup...'); $log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart); - // Step 3: Extract backup to temp directory + // Step 3: Delegate to BackupManager for core restoration $stepStart = microtime(true); - $tempDir = sys_get_temp_dir() . '/partdb_restore_' . uniqid(); - $this->filesystem->mkdir($tempDir); + $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); + } + ); - $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 4: Restore database if requested and present - if ($restoreDatabase) { - $stepStart = microtime(true); - $this->restoreDatabaseFromBackup($tempDir); - $log('database', 'Restored database', true, microtime(true) - $stepStart); + if (!$result['success']) { + throw new \RuntimeException($result['error'] ?? 'Restore failed'); } - // Step 5: 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 6: Restore attachments if requested and present - if ($restoreAttachments) { - $stepStart = microtime(true); - $this->restoreAttachmentsFromBackup($tempDir); - $log('attachments', 'Restored attachments', true, microtime(true) - $stepStart); - } - - // Step 7: Clean up temp directory - $stepStart = microtime(true); - $this->filesystem->remove($tempDir); - $log('cleanup', 'Cleaned up temporary files', true, microtime(true) - $stepStart); - - // Step 8: Clear cache + // 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 9: Warm up cache + // 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 10: Disable maintenance mode + // Step 6: Disable maintenance mode $stepStart = microtime(true); $this->disableMaintenanceMode(); $log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart); - // Step 11: Release lock + // Step 7: Release lock $this->releaseLock(); $totalDuration = microtime(true) - $startTime; - $log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true, microtime(true) - $stepStart); + $log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true); return [ 'success' => true, @@ -808,9 +739,6 @@ class UpdateExecutor try { $this->disableMaintenanceMode(); $this->releaseLock(); - 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()]); } @@ -823,137 +751,6 @@ class UpdateExecutor } } - /** - * 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->project_dir, 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->project_dir, $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->project_dir . '/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->project_dir . '/.env.local', true); - } - - // Restore config/parameters.yaml - $parametersYaml = $tempDir . '/config/parameters.yaml'; - if (file_exists($parametersYaml)) { - $this->filesystem->copy($parametersYaml, $this->project_dir . '/config/parameters.yaml', true); - } - - // Restore config/banner.md - $bannerMd = $tempDir . '/config/banner.md'; - if (file_exists($bannerMd)) { - $this->filesystem->copy($bannerMd, $this->project_dir . '/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->project_dir . '/public/media', null, ['override' => true]); - } - - // Restore uploads - $uploads = $tempDir . '/uploads'; - if (is_dir($uploads)) { - $this->filesystem->mirror($uploads, $this->project_dir . '/uploads', null, ['override' => true]); - } - } - /** * Get the path to the progress file. */ @@ -1048,7 +845,7 @@ class UpdateExecutor 'create_backup' => $createBackup, 'started_at' => (new \DateTime())->format('c'), 'current_step' => 0, - 'total_steps' => 12, + 'total_steps' => 14, 'step_name' => 'initializing', 'step_message' => 'Starting update process...', 'steps' => [], diff --git a/templates/admin/update_manager/index.html.twig b/templates/admin/update_manager/index.html.twig index 85b3ec1f..24dfcc96 100644 --- a/templates/admin/update_manager/index.html.twig +++ b/templates/admin/update_manager/index.html.twig @@ -34,6 +34,23 @@ {% endif %} + {# Web Updates Disabled Warning #} + {% if web_updates_disabled %} + + {% endif %} + + {# Backup Restore Disabled Warning #} + {% if backup_restore_disabled %} + + {% endif %} +
{# Current Version Card #}
@@ -132,7 +149,7 @@ {% endif %}
- {% if status.update_available and status.can_auto_update and validation.valid %} + {% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %}
- {% if release.version != status.current_version and status.can_auto_update and validation.valid %} + {% if release.version != status.current_version and status.can_auto_update and validation.valid and not web_updates_disabled %} - {% if status.can_auto_update and validation.valid %} + {% if status.can_auto_update and validation.valid and not backup_restore_disabled %} WARNING: This will overwrite your current database with the backup data. This action cannot be undone! Make sure you have a current backup before proceeding. + + + update_manager.web_updates_disabled + Web-based updates are disabled + + + + + update_manager.web_updates_disabled_hint + Web-based updates have been disabled by the server administrator. Please use the CLI command "php bin/console partdb:update" to perform updates. + + + + + update_manager.backup_restore_disabled + Backup restore is disabled by server configuration. + + settings.ips.conrad From 47295bda29468213abfb8f096d54f230eb55bc29 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:28:15 +0100 Subject: [PATCH 40/78] Add unit tests for BackupManager and UpdateExecutor Tests cover: - BackupManager: backup directory, listing, details parsing - UpdateExecutor: lock/unlock, maintenance mode, validation, progress --- tests/Services/System/BackupManagerTest.php | 102 +++++++++++ tests/Services/System/UpdateExecutorTest.php | 173 +++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 tests/Services/System/BackupManagerTest.php create mode 100644 tests/Services/System/UpdateExecutorTest.php diff --git a/tests/Services/System/BackupManagerTest.php b/tests/Services/System/BackupManagerTest.php new file mode 100644 index 00000000..145b039d --- /dev/null +++ b/tests/Services/System/BackupManagerTest.php @@ -0,0 +1,102 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\Services\System; + +use App\Services\System\BackupManager; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class BackupManagerTest extends KernelTestCase +{ + private ?BackupManager $backupManager = null; + + protected function setUp(): void + { + self::bootKernel(); + $this->backupManager = self::getContainer()->get(BackupManager::class); + } + + public function testGetBackupDir(): void + { + $backupDir = $this->backupManager->getBackupDir(); + + // Should end with var/backups + $this->assertStringEndsWith('var/backups', $backupDir); + } + + public function testGetBackupsReturnsEmptyArrayWhenNoBackups(): void + { + // If there are no backups (or the directory doesn't exist), should return empty array + $backups = $this->backupManager->getBackups(); + + $this->assertIsArray($backups); + } + + public function testGetBackupDetailsReturnsNullForNonExistentFile(): void + { + $details = $this->backupManager->getBackupDetails('non-existent-backup.zip'); + + $this->assertNull($details); + } + + public function testGetBackupDetailsReturnsNullForNonZipFile(): void + { + $details = $this->backupManager->getBackupDetails('not-a-zip.txt'); + + $this->assertNull($details); + } + + /** + * Test that version parsing from filename works correctly. + * This tests the regex pattern used in getBackupDetails. + */ + public function testVersionParsingFromFilename(): void + { + // Test the regex pattern directly + $filename = 'pre-update-v2.5.1-to-v2.6.0-2024-01-30-185400.zip'; + $matches = []; + + $result = preg_match('/pre-update-v([\d.]+)-to-v?([\d.]+)-/', $filename, $matches); + + $this->assertEquals(1, $result); + $this->assertEquals('2.5.1', $matches[1]); + $this->assertEquals('2.6.0', $matches[2]); + } + + /** + * Test version parsing with different filename formats. + */ + public function testVersionParsingVariants(): void + { + // Without 'v' prefix on target version + $filename1 = 'pre-update-v1.0.0-to-2.0.0-2024-01-30-185400.zip'; + preg_match('/pre-update-v([\d.]+)-to-v?([\d.]+)-/', $filename1, $matches1); + $this->assertEquals('1.0.0', $matches1[1]); + $this->assertEquals('2.0.0', $matches1[2]); + + // With 'v' prefix on target version + $filename2 = 'pre-update-v1.0.0-to-v2.0.0-2024-01-30-185400.zip'; + preg_match('/pre-update-v([\d.]+)-to-v?([\d.]+)-/', $filename2, $matches2); + $this->assertEquals('1.0.0', $matches2[1]); + $this->assertEquals('2.0.0', $matches2[2]); + } +} diff --git a/tests/Services/System/UpdateExecutorTest.php b/tests/Services/System/UpdateExecutorTest.php new file mode 100644 index 00000000..9b832f6c --- /dev/null +++ b/tests/Services/System/UpdateExecutorTest.php @@ -0,0 +1,173 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\Services\System; + +use App\Services\System\UpdateExecutor; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class UpdateExecutorTest extends KernelTestCase +{ + private ?UpdateExecutor $updateExecutor = null; + + protected function setUp(): void + { + self::bootKernel(); + $this->updateExecutor = self::getContainer()->get(UpdateExecutor::class); + } + + public function testIsLockedReturnsFalseWhenNoLockFile(): void + { + // Initially there should be no lock + // Note: This test assumes no concurrent update is running + $isLocked = $this->updateExecutor->isLocked(); + + $this->assertIsBool($isLocked); + } + + public function testIsMaintenanceModeReturnsBool(): void + { + $isMaintenanceMode = $this->updateExecutor->isMaintenanceMode(); + + $this->assertIsBool($isMaintenanceMode); + } + + public function testGetLockInfoReturnsNullOrArray(): void + { + $lockInfo = $this->updateExecutor->getLockInfo(); + + // Should be null when not locked, or array when locked + $this->assertTrue($lockInfo === null || is_array($lockInfo)); + } + + public function testGetMaintenanceInfoReturnsNullOrArray(): void + { + $maintenanceInfo = $this->updateExecutor->getMaintenanceInfo(); + + // Should be null when not in maintenance, or array when in maintenance + $this->assertTrue($maintenanceInfo === null || is_array($maintenanceInfo)); + } + + public function testGetUpdateLogsReturnsArray(): void + { + $logs = $this->updateExecutor->getUpdateLogs(); + + $this->assertIsArray($logs); + } + + public function testGetBackupsReturnsArray(): void + { + $backups = $this->updateExecutor->getBackups(); + + $this->assertIsArray($backups); + } + + public function testValidateUpdatePreconditionsReturnsProperStructure(): void + { + $validation = $this->updateExecutor->validateUpdatePreconditions(); + + $this->assertIsArray($validation); + $this->assertArrayHasKey('valid', $validation); + $this->assertArrayHasKey('errors', $validation); + $this->assertIsBool($validation['valid']); + $this->assertIsArray($validation['errors']); + } + + public function testGetProgressFilePath(): void + { + $progressPath = $this->updateExecutor->getProgressFilePath(); + + $this->assertIsString($progressPath); + $this->assertStringEndsWith('var/update_progress.json', $progressPath); + } + + public function testGetProgressReturnsNullOrArray(): void + { + $progress = $this->updateExecutor->getProgress(); + + // Should be null when no progress file, or array when exists + $this->assertTrue($progress === null || is_array($progress)); + } + + public function testIsUpdateRunningReturnsBool(): void + { + $isRunning = $this->updateExecutor->isUpdateRunning(); + + $this->assertIsBool($isRunning); + } + + public function testAcquireAndReleaseLock(): void + { + // First, ensure no lock exists + if ($this->updateExecutor->isLocked()) { + $this->updateExecutor->releaseLock(); + } + + // Acquire lock + $acquired = $this->updateExecutor->acquireLock(); + $this->assertTrue($acquired); + + // Should be locked now + $this->assertTrue($this->updateExecutor->isLocked()); + + // Lock info should exist + $lockInfo = $this->updateExecutor->getLockInfo(); + $this->assertIsArray($lockInfo); + $this->assertArrayHasKey('started_at', $lockInfo); + + // Trying to acquire again should fail + $acquiredAgain = $this->updateExecutor->acquireLock(); + $this->assertFalse($acquiredAgain); + + // Release lock + $this->updateExecutor->releaseLock(); + + // Should no longer be locked + $this->assertFalse($this->updateExecutor->isLocked()); + } + + public function testEnableAndDisableMaintenanceMode(): void + { + // First, ensure maintenance mode is off + if ($this->updateExecutor->isMaintenanceMode()) { + $this->updateExecutor->disableMaintenanceMode(); + } + + // Enable maintenance mode + $this->updateExecutor->enableMaintenanceMode('Test maintenance'); + + // Should be in maintenance mode now + $this->assertTrue($this->updateExecutor->isMaintenanceMode()); + + // Maintenance info should exist + $maintenanceInfo = $this->updateExecutor->getMaintenanceInfo(); + $this->assertIsArray($maintenanceInfo); + $this->assertArrayHasKey('reason', $maintenanceInfo); + $this->assertEquals('Test maintenance', $maintenanceInfo['reason']); + + // Disable maintenance mode + $this->updateExecutor->disableMaintenanceMode(); + + // Should no longer be in maintenance mode + $this->assertFalse($this->updateExecutor->isMaintenanceMode()); + } +} From 10acc2e1300cad57766ba82c7e0ad9042817b232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 1 Feb 2026 20:49:50 +0100 Subject: [PATCH 41/78] Added logic to delegate the info retrieval logic to another provider when giving an URL --- .../InfoProviderSystem/ProviderRegistry.php | 38 +++++++++++++++- .../Providers/GenericWebProvider.php | 44 ++++++++++++++++++- .../Providers/PollinProvider.php | 37 +++++++++++++--- .../URLHandlerInfoProviderInterface.php | 43 ++++++++++++++++++ .../ProviderRegistryTest.php | 18 +++++++- 5 files changed, 169 insertions(+), 11 deletions(-) create mode 100644 src/Services/InfoProviderSystem/Providers/URLHandlerInfoProviderInterface.php diff --git a/src/Services/InfoProviderSystem/ProviderRegistry.php b/src/Services/InfoProviderSystem/ProviderRegistry.php index f6c398d2..18b8a37a 100644 --- a/src/Services/InfoProviderSystem/ProviderRegistry.php +++ b/src/Services/InfoProviderSystem/ProviderRegistry.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use App\Services\InfoProviderSystem\Providers\URLHandlerInfoProviderInterface; /** * This class keeps track of all registered info providers and allows to find them by their key @@ -47,6 +48,8 @@ final class ProviderRegistry */ private array $providers_disabled = []; + private array $providers_by_domain = []; + /** * @var bool Whether the registry has been initialized */ @@ -78,6 +81,14 @@ final class ProviderRegistry $this->providers_by_name[$key] = $provider; if ($provider->isActive()) { $this->providers_active[$key] = $provider; + if ($provider instanceof URLHandlerInfoProviderInterface) { + foreach ($provider->getHandledDomains() as $domain) { + if (isset($this->providers_by_domain[$domain])) { + throw new \LogicException("Domain $domain is already handled by another provider"); + } + $this->providers_by_domain[$domain] = $provider; + } + } } else { $this->providers_disabled[$key] = $provider; } @@ -139,4 +150,29 @@ final class ProviderRegistry return $this->providers_disabled; } -} \ No newline at end of file + + public function getProviderHandlingDomain(string $domain): (InfoProviderInterface&URLHandlerInfoProviderInterface)|null + { + if (!$this->initialized) { + $this->initStructures(); + } + + //Check if the domain is directly existing: + if (isset($this->providers_by_domain[$domain])) { + return $this->providers_by_domain[$domain]; + } + + //Otherwise check for subdomains: + $parts = explode('.', $domain); + while (count($parts) > 2) { + array_shift($parts); + $check_domain = implode('.', $parts); + if (isset($this->providers_by_domain[$check_domain])) { + return $this->providers_by_domain[$check_domain]; + } + } + + //If we found nothing, return null + return null; + } +} diff --git a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php index 4b73ad6e..bca3d7cb 100644 --- a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php +++ b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php @@ -28,8 +28,9 @@ use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; +use App\Services\InfoProviderSystem\PartInfoRetriever; +use App\Services\InfoProviderSystem\ProviderRegistry; use App\Settings\InfoProviderSystem\GenericWebProviderSettings; -use PhpOffice\PhpSpreadsheet\Calculation\Financial\Securities\Price; use Symfony\Component\DomCrawler\Crawler; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -40,7 +41,9 @@ class GenericWebProvider implements InfoProviderInterface private readonly HttpClientInterface $httpClient; - public function __construct(HttpClientInterface $httpClient, private readonly GenericWebProviderSettings $settings) + public function __construct(HttpClientInterface $httpClient, private readonly GenericWebProviderSettings $settings, + private readonly ProviderRegistry $providerRegistry, private readonly PartInfoRetriever $infoRetriever, + ) { $this->httpClient = $httpClient->withOptions( [ @@ -228,6 +231,37 @@ class GenericWebProvider implements InfoProviderInterface return null; } + /** + * Delegates the URL to another provider if possible, otherwise return null + * @param string $url + * @return PartDetailDTO|null + */ + private function delegateToOtherProvider(string $url): ?PartDetailDTO + { + //Extract domain from url: + $host = parse_url($url, PHP_URL_HOST); + if ($host === false || $host === null) { + return null; + } + + $provider = $this->providerRegistry->getProviderHandlingDomain($host); + + if ($provider !== null && $provider->isActive() && $provider->getProviderKey() !== $this->getProviderKey()) { + try { + $id = $provider->getIDFromURL($url); + if ($id !== null) { + return $this->infoRetriever->getDetails($provider->getProviderKey(), $id); + } + return null; + } catch (ProviderIDNotSupportedException $e) { + //Ignore and continue + return null; + } + } + + return null; + } + public function getDetails(string $id): PartDetailDTO { //Add scheme if missing @@ -247,6 +281,12 @@ class GenericWebProvider implements InfoProviderInterface throw new ProviderIDNotSupportedException("The given ID is not a valid URL: ".$id); } + //Before loading the page, try to delegate to another provider + $delegatedPart = $this->delegateToOtherProvider($url); + if ($delegatedPart !== null) { + return $delegatedPart; + } + //Try to get the webpage content $response = $this->httpClient->request('GET', $url); $content = $response->getContent(); diff --git a/src/Services/InfoProviderSystem/Providers/PollinProvider.php b/src/Services/InfoProviderSystem/Providers/PollinProvider.php index 2c5d68a3..6ac969d3 100644 --- a/src/Services/InfoProviderSystem/Providers/PollinProvider.php +++ b/src/Services/InfoProviderSystem/Providers/PollinProvider.php @@ -36,7 +36,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DomCrawler\Crawler; use Symfony\Contracts\HttpClient\HttpClientInterface; -class PollinProvider implements InfoProviderInterface +class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface { public function __construct(private readonly HttpClientInterface $client, @@ -141,11 +141,16 @@ class PollinProvider implements InfoProviderInterface $orderId = trim($dom->filter('span[itemprop="sku"]')->text()); //Text is important here //Calculate the mass - $massStr = $dom->filter('meta[itemprop="weight"]')->attr('content'); - //Remove the unit - $massStr = str_replace('kg', '', $massStr); - //Convert to float and convert to grams - $mass = (float) $massStr * 1000; + $massDom = $dom->filter('meta[itemprop="weight"]'); + if ($massDom->count() > 0) { + $massStr = $massDom->attr('content'); + $massStr = str_replace('kg', '', $massStr); + //Convert to float and convert to grams + $mass = (float) $massStr * 1000; + } else { + $mass = null; + } + //Parse purchase info $purchaseInfo = new PurchaseInfoDTO('Pollin', $orderId, $this->parsePrices($dom), $productPageUrl); @@ -248,4 +253,22 @@ class PollinProvider implements InfoProviderInterface ProviderCapabilities::DATASHEET ]; } -} \ No newline at end of file + + public function getHandledDomains(): array + { + return ['pollin.de']; + } + + public function getIDFromURL(string $url): ?string + { + //URL like: https://www.pollin.de/p/shelly-bluetooth-schalter-und-dimmer-blu-zb-button-plug-play-mocha-592325 + + //Extract the 6-digit number at the end of the URL + $matches = []; + if (preg_match('/-(\d{6})(?:\/|$)/', $url, $matches)) { + return $matches[1]; + } + + return null; + } +} diff --git a/src/Services/InfoProviderSystem/Providers/URLHandlerInfoProviderInterface.php b/src/Services/InfoProviderSystem/Providers/URLHandlerInfoProviderInterface.php new file mode 100644 index 00000000..c0506648 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/URLHandlerInfoProviderInterface.php @@ -0,0 +1,43 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +/** + * If an interface + */ +interface URLHandlerInfoProviderInterface +{ + /** + * Returns a list of supported domains (e.g. ["digikey.com"]) + * @return array An array of supported domains + */ + public function getHandledDomains(): array; + + /** + * Extracts the unique ID of a part from a given URL. It is okay if this is not a canonical ID, as long as it can be used to uniquely identify the part within this provider. + * @param string $url The URL to extract the ID from + * @return string|null The extracted ID, or null if the URL is not valid for this provider + */ + public function getIDFromURL(string $url): ?string; +} diff --git a/tests/Services/InfoProviderSystem/ProviderRegistryTest.php b/tests/Services/InfoProviderSystem/ProviderRegistryTest.php index 9026c5bf..48a1847f 100644 --- a/tests/Services/InfoProviderSystem/ProviderRegistryTest.php +++ b/tests/Services/InfoProviderSystem/ProviderRegistryTest.php @@ -24,6 +24,7 @@ namespace App\Tests\Services\InfoProviderSystem; use App\Services\InfoProviderSystem\ProviderRegistry; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use App\Services\InfoProviderSystem\Providers\URLHandlerInfoProviderInterface; use PHPUnit\Framework\TestCase; class ProviderRegistryTest extends TestCase @@ -44,9 +45,10 @@ class ProviderRegistryTest extends TestCase public function getMockProvider(string $key, bool $active = true): InfoProviderInterface { - $mock = $this->createMock(InfoProviderInterface::class); + $mock = $this->createMockForIntersectionOfInterfaces([InfoProviderInterface::class, URLHandlerInfoProviderInterface::class]); $mock->method('getProviderKey')->willReturn($key); $mock->method('isActive')->willReturn($active); + $mock->method('getHandledDomains')->willReturn(["$key.com", "test.$key.de"]); return $mock; } @@ -109,4 +111,18 @@ class ProviderRegistryTest extends TestCase $registry->getProviders(); } + + public function testGetProviderHandlingDomain(): void + { + $registry = new ProviderRegistry($this->providers); + + $this->assertEquals($this->providers[0], $registry->getProviderHandlingDomain('test1.com')); + $this->assertEquals($this->providers[0], $registry->getProviderHandlingDomain('www.test1.com')); //Subdomain should also work + + $this->assertEquals( + $this->providers[1], + $registry->getProviderHandlingDomain('test.test2.de') + ); + } + } From 24f0f0d23c325a23ab4d7d000b90e36e1fd80975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 1 Feb 2026 21:18:06 +0100 Subject: [PATCH 42/78] Added URL handling to a few more existing info providers --- .../Providers/ConradProvider.php | 25 ++++++++- .../Providers/Element14Provider.php | 19 ++++++- .../Providers/GenericWebProvider.php | 51 +++++++++++++------ .../Providers/LCSCProvider.php | 19 ++++++- .../Providers/TMEProvider.php | 20 +++++++- 5 files changed, 115 insertions(+), 19 deletions(-) diff --git a/src/Services/InfoProviderSystem/Providers/ConradProvider.php b/src/Services/InfoProviderSystem/Providers/ConradProvider.php index 6212f148..32434dee 100644 --- a/src/Services/InfoProviderSystem/Providers/ConradProvider.php +++ b/src/Services/InfoProviderSystem/Providers/ConradProvider.php @@ -30,9 +30,10 @@ use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Settings\InfoProviderSystem\ConradSettings; +use App\Settings\InfoProviderSystem\ConradShopIDs; use Symfony\Contracts\HttpClient\HttpClientInterface; -readonly class ConradProvider implements InfoProviderInterface +readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface { private const SEARCH_ENDPOINT = '/search/1/v3/facetSearch'; @@ -317,4 +318,26 @@ readonly class ConradProvider implements InfoProviderInterface ProviderCapabilities::PRICE, ]; } + + public function getHandledDomains(): array + { + $domains = []; + foreach (ConradShopIDs::cases() as $shopID) { + $domains[] = $shopID->getDomain(); + } + return array_unique($domains); + } + + public function getIDFromURL(string $url): ?string + { + //Input: https://www.conrad.de/de/p/apple-iphone-air-wolkenweiss-256-gb-eek-a-a-g-16-5-cm-6-5-zoll-3475299.html + //The numbers before the optional .html are the product ID + + $matches = []; + if (preg_match('/-(\d+)(\.html)?$/', $url, $matches) === 1) { + return $matches[1]; + } + + return null; + } } diff --git a/src/Services/InfoProviderSystem/Providers/Element14Provider.php b/src/Services/InfoProviderSystem/Providers/Element14Provider.php index 27dfb908..9ae45728 100644 --- a/src/Services/InfoProviderSystem/Providers/Element14Provider.php +++ b/src/Services/InfoProviderSystem/Providers/Element14Provider.php @@ -33,7 +33,7 @@ use App\Settings\InfoProviderSystem\Element14Settings; use Composer\CaBundle\CaBundle; use Symfony\Contracts\HttpClient\HttpClientInterface; -class Element14Provider implements InfoProviderInterface +class Element14Provider implements InfoProviderInterface, URLHandlerInfoProviderInterface { private const ENDPOINT_URL = 'https://api.element14.com/catalog/products'; @@ -309,4 +309,21 @@ class Element14Provider implements InfoProviderInterface ProviderCapabilities::DATASHEET, ]; } + + public function getHandledDomains(): array + { + return ['element14.com', 'farnell.com', 'newark.com']; + } + + public function getIDFromURL(string $url): ?string + { + //Input URL example: https://de.farnell.com/on-semiconductor/bc547b/transistor-npn-to-92/dp/1017673 + //The digits after the /dp/ are the part ID + $matches = []; + if (preg_match('#/dp/(\d+)#', $url, $matches) === 1) { + return $matches[1]; + } + + return null; + } } diff --git a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php index bca3d7cb..d06a6105 100644 --- a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php +++ b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php @@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\InfoProviderSystem\PartInfoRetriever; use App\Services\InfoProviderSystem\ProviderRegistry; use App\Settings\InfoProviderSystem\GenericWebProviderSettings; @@ -78,9 +79,17 @@ class GenericWebProvider implements InfoProviderInterface public function searchByKeyword(string $keyword): array { + $url = $this->fixAndValidateURL($keyword); + + //Before loading the page, try to delegate to another provider + $delegatedPart = $this->delegateToOtherProvider($url); + if ($delegatedPart !== null) { + return [$delegatedPart]; + } + try { return [ - $this->getDetails($keyword) + $this->getDetails($keyword, false) //We already tried delegation ]; } catch (ProviderIDNotSupportedException $e) { return []; } @@ -234,9 +243,9 @@ class GenericWebProvider implements InfoProviderInterface /** * Delegates the URL to another provider if possible, otherwise return null * @param string $url - * @return PartDetailDTO|null + * @return SearchResultDTO|null */ - private function delegateToOtherProvider(string $url): ?PartDetailDTO + private function delegateToOtherProvider(string $url): ?SearchResultDTO { //Extract domain from url: $host = parse_url($url, PHP_URL_HOST); @@ -250,7 +259,10 @@ class GenericWebProvider implements InfoProviderInterface try { $id = $provider->getIDFromURL($url); if ($id !== null) { - return $this->infoRetriever->getDetails($provider->getProviderKey(), $id); + $results = $this->infoRetriever->searchByKeyword($id, [$provider]); + if (count($results) > 0) { + return $results[0]; + } } return null; } catch (ProviderIDNotSupportedException $e) { @@ -262,29 +274,38 @@ class GenericWebProvider implements InfoProviderInterface return null; } - public function getDetails(string $id): PartDetailDTO + private function fixAndValidateURL(string $url): string { + $originalUrl = $url; + //Add scheme if missing - if (!preg_match('/^https?:\/\//', $id)) { + if (!preg_match('/^https?:\/\//', $url)) { //Remove any leading slashes - $id = ltrim($id, '/'); + $url = ltrim($url, '/'); - $id = 'https://'.$id; + $url = 'https://'.$url; } - $url = $id; - //If this is not a valid URL with host, domain and path, throw an exception if (filter_var($url, FILTER_VALIDATE_URL) === false || parse_url($url, PHP_URL_HOST) === null || parse_url($url, PHP_URL_PATH) === null) { - throw new ProviderIDNotSupportedException("The given ID is not a valid URL: ".$id); + throw new ProviderIDNotSupportedException("The given ID is not a valid URL: ".$originalUrl); } - //Before loading the page, try to delegate to another provider - $delegatedPart = $this->delegateToOtherProvider($url); - if ($delegatedPart !== null) { - return $delegatedPart; + return $url; + } + + public function getDetails(string $id, bool $check_for_delegation = true): PartDetailDTO + { + $url = $this->fixAndValidateURL($id); + + if ($check_for_delegation) { + //Before loading the page, try to delegate to another provider + $delegatedPart = $this->delegateToOtherProvider($url); + if ($delegatedPart !== null) { + return $delegatedPart; + } } //Try to get the webpage content diff --git a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php index ede34eb8..1b807eff 100755 --- a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php +++ b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php @@ -33,7 +33,7 @@ use App\Settings\InfoProviderSystem\LCSCSettings; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Contracts\HttpClient\HttpClientInterface; -class LCSCProvider implements BatchInfoProviderInterface +class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProviderInterface { private const ENDPOINT_URL = 'https://wmsc.lcsc.com/ftps/wm'; @@ -452,4 +452,21 @@ class LCSCProvider implements BatchInfoProviderInterface ProviderCapabilities::FOOTPRINT, ]; } + + public function getHandledDomains(): array + { + return ['lcsc.com']; + } + + public function getIDFromURL(string $url): ?string + { + //Input example: https://www.lcsc.com/product-detail/C258144.html?s_z=n_BC547 + //The part between the "C" and the ".html" is the unique ID + + $matches = []; + if (preg_match("#/product-detail/(\w+)\.html#", $url, $matches) > 0) { + return $matches[1]; + } + return null; + } } diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php index 9bc73f09..938bc7b3 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -32,7 +32,7 @@ use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Settings\InfoProviderSystem\TMESettings; -class TMEProvider implements InfoProviderInterface +class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterface { private const VENDOR_NAME = 'TME'; @@ -296,4 +296,22 @@ class TMEProvider implements InfoProviderInterface ProviderCapabilities::PRICE, ]; } + + public function getHandledDomains(): array + { + return ['tme.eu']; + } + + public function getIDFromURL(string $url): ?string + { + //Input: https://www.tme.eu/de/details/fi321_se/kuhler/alutronic/ + //The ID is the part after the details segment and before the next slash + + $matches = []; + if (preg_match('#/details/([^/]+)/#', $url, $matches) === 1) { + return $matches[1]; + } + + return null; + } } From a1396c6696f4a9b6421a400b529a42176b3ea9cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 1 Feb 2026 21:19:11 +0100 Subject: [PATCH 43/78] Fixed delegation logic for PartDetailDTO --- .../InfoProviderSystem/Providers/GenericWebProvider.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php index d06a6105..66d45707 100644 --- a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php +++ b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php @@ -224,6 +224,12 @@ class GenericWebProvider implements InfoProviderInterface 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 + * @param string $name + * @return string|null + */ private function getMetaContent(Crawler $dom, string $name): ?string { $meta = $dom->filter('meta[property="'.$name.'"]'); @@ -304,7 +310,7 @@ class GenericWebProvider implements InfoProviderInterface //Before loading the page, try to delegate to another provider $delegatedPart = $this->delegateToOtherProvider($url); if ($delegatedPart !== null) { - return $delegatedPart; + return $this->infoRetriever->getDetailsForSearchResult($delegatedPart); } } From 0826acbd5282fad8361117660a1f762060aa690b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 1 Feb 2026 23:11:56 +0100 Subject: [PATCH 44/78] Fixed phpunit tests --- .../LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php index c40e141d..c5bdb02d 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeRedirectorTest.php @@ -64,7 +64,7 @@ final class BarcodeRedirectorTest extends KernelTestCase { yield [new LocalBarcodeScanResult(LabelSupportedElement::PART, 1, BarcodeSourceType::INTERNAL), '/en/part/1']; //Part lot redirects to Part info page (Part lot 1 is associated with part 3) - yield [new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 1, BarcodeSourceType::INTERNAL), '/en/part/3']; + yield [new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 1, BarcodeSourceType::INTERNAL), '/en/part/3?highlightLot=1']; yield [new LocalBarcodeScanResult(LabelSupportedElement::STORELOCATION, 1, BarcodeSourceType::INTERNAL), '/en/store_location/1/parts']; } From 7e486a93c9b6e7cd25ce93bf5b198330e9dfc383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 17:02:01 +0100 Subject: [PATCH 45/78] Added missing phpdoc structure definitions --- src/Services/System/UpdateChecker.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Services/System/UpdateChecker.php b/src/Services/System/UpdateChecker.php index a881f614..49a132ee 100644 --- a/src/Services/System/UpdateChecker.php +++ b/src/Services/System/UpdateChecker.php @@ -72,6 +72,7 @@ class UpdateChecker /** * Get Git repository information. + * @return array{branch: ?string, commit: ?string, has_local_changes: bool, commits_behind: int, is_git_install: bool} */ public function getGitInfo(): array { @@ -227,6 +228,7 @@ class UpdateChecker /** * 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 getLatestRelease(bool $includePrerelease = false): ?array { @@ -264,6 +266,8 @@ class UpdateChecker /** * 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 { @@ -318,6 +322,7 @@ class UpdateChecker /** * Get releases newer than the current version. + * @return array */ public function getAvailableUpdates(bool $includePrerelease = false): array { From 1bfd36ccf59c56d076fb0d35312119b5adfd3328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 17:04:45 +0100 Subject: [PATCH 46/78] Do not automatically give existing users the right to manage updates, but include that for new databases --- src/Entity/UserSystem/PermissionData.php | 2 +- .../UserSystem/PermissionPresetsHelper.php | 3 ++- .../UserSystem/PermissionSchemaUpdater.php | 17 ----------------- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/src/Entity/UserSystem/PermissionData.php b/src/Entity/UserSystem/PermissionData.php index b7d1ff8f..9ebdc9c9 100644 --- a/src/Entity/UserSystem/PermissionData.php +++ b/src/Entity/UserSystem/PermissionData.php @@ -43,7 +43,7 @@ final class PermissionData implements \JsonSerializable /** * The current schema version of the permission data */ - public const CURRENT_SCHEMA_VERSION = 4; + public const CURRENT_SCHEMA_VERSION = 3; /** * Creates a new Permission Data Instance using the given data. diff --git a/src/Services/UserSystem/PermissionPresetsHelper.php b/src/Services/UserSystem/PermissionPresetsHelper.php index a3ed01b8..3d125b27 100644 --- a/src/Services/UserSystem/PermissionPresetsHelper.php +++ b/src/Services/UserSystem/PermissionPresetsHelper.php @@ -111,8 +111,9 @@ class PermissionPresetsHelper //Allow to manage Oauth tokens $this->permissionResolver->setPermission($perm_holder, 'system', 'manage_oauth_tokens', PermissionData::ALLOW); - //Allow to show updates + //Allow to show and manage updates $this->permissionResolver->setPermission($perm_holder, 'system', 'show_updates', PermissionData::ALLOW); + $this->permissionResolver->setPermission($perm_holder, 'system', 'manage_updates', PermissionData::ALLOW); } diff --git a/src/Services/UserSystem/PermissionSchemaUpdater.php b/src/Services/UserSystem/PermissionSchemaUpdater.php index b3341322..104800dc 100644 --- a/src/Services/UserSystem/PermissionSchemaUpdater.php +++ b/src/Services/UserSystem/PermissionSchemaUpdater.php @@ -157,21 +157,4 @@ class PermissionSchemaUpdater $permissions->setPermissionValue('system', 'show_updates', $new_value); } } - - private function upgradeSchemaToVersion4(HasPermissionsInterface $holder): void //@phpstan-ignore-line This is called via reflection - { - $permissions = $holder->getPermissions(); - - //If the system.manage_updates permission is not defined yet, set it to true if the user can show updates AND has server_infos permission - //This ensures that admins who can view updates and server info can also manage (execute) updates - if (!$permissions->isPermissionSet('system', 'manage_updates')) { - - $new_value = TrinaryLogicHelper::and( - $permissions->getPermissionValue('system', 'show_updates'), - $permissions->getPermissionValue('system', 'server_infos') - ); - - $permissions->setPermissionValue('system', 'manage_updates', $new_value); - } - } } From 7ff07a7ab43d47ed0a4a7f4a462000a80e7c6039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 17:28:35 +0100 Subject: [PATCH 47/78] Remove Content-Security-Policy for maintenance mode --- .../MaintenanceModeSubscriber.php | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/EventSubscriber/MaintenanceModeSubscriber.php b/src/EventSubscriber/MaintenanceModeSubscriber.php index 60623b45..74b219a0 100644 --- a/src/EventSubscriber/MaintenanceModeSubscriber.php +++ b/src/EventSubscriber/MaintenanceModeSubscriber.php @@ -26,17 +26,19 @@ 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. */ -class MaintenanceModeSubscriber implements EventSubscriberInterface +readonly class MaintenanceModeSubscriber implements EventSubscriberInterface { - public function __construct(private readonly UpdateExecutor $updateExecutor, - private readonly Environment $twig) + public function __construct(private UpdateExecutor $updateExecutor, + private Environment $twig) { } @@ -45,7 +47,8 @@ class MaintenanceModeSubscriber implements EventSubscriberInterface { return [ // High priority to run before other listeners - KernelEvents::REQUEST => ['onKernelRequest', 512], + KernelEvents::REQUEST => ['onKernelRequest', 512], //High priority to run before other listeners + KernelEvents::RESPONSE => ['onKernelResponse', -512] // Low priority to run after other listeners ]; } @@ -62,7 +65,7 @@ class MaintenanceModeSubscriber implements EventSubscriberInterface } // Allow CLI requests - if (php_sapi_name() === 'cli') { + if (PHP_SAPI === 'cli') { return; } @@ -101,6 +104,28 @@ class MaintenanceModeSubscriber implements EventSubscriberInterface $event->setResponse($response); } + public function onKernelResponse(ResponseEvent $event) + { + // Only handle main requests + if (!$event->isMainRequest()) { + return; + } + + // Skip if not in maintenance mode + if (!$this->updateExecutor->isMaintenanceMode()) { + return; + } + + // Allow CLI requests + if (PHP_SAPI === 'cli') { + return; + } + + //Remove all Content-Security-Policy headers to allow loading resources during maintenance + $response = $event->getResponse(); + $response->headers->remove('Content-Security-Policy'); + } + /** * Generate a simple maintenance page HTML without Twig. */ From 6dbead6d109d02cda1103b771aacb3d0d386c8ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 18:18:36 +0100 Subject: [PATCH 48/78] Centralized git logic from InstallationTypeDetector and UpdateChecker in GitVersionInfoProvider service --- src/Command/UpdateCommand.php | 3 +- src/Command/VersionCommand.php | 12 +- src/Controller/HomepageController.php | 8 +- src/Controller/ToolsController.php | 8 +- src/Controller/UpdateManagerController.php | 12 +- src/Services/Misc/GitVersionInfo.php | 83 ----------- .../System/GitVersionInfoProvider.php | 135 ++++++++++++++++++ src/Services/System/InstallationType.php | 65 +++++++++ .../System/InstallationTypeDetector.php | 92 ++---------- src/Services/System/UpdateChecker.php | 30 +--- src/State/PartDBInfoProvider.php | 8 +- 11 files changed, 242 insertions(+), 214 deletions(-) delete mode 100644 src/Services/Misc/GitVersionInfo.php create mode 100644 src/Services/System/GitVersionInfoProvider.php create mode 100644 src/Services/System/InstallationType.php diff --git a/src/Command/UpdateCommand.php b/src/Command/UpdateCommand.php index 4f2cae86..64fa2bad 100644 --- a/src/Command/UpdateCommand.php +++ b/src/Command/UpdateCommand.php @@ -23,7 +23,6 @@ declare(strict_types=1); namespace App\Command; -use App\Services\System\InstallationType; use App\Services\System\UpdateChecker; use App\Services\System\UpdateExecutor; use Symfony\Component\Console\Attribute\AsCommand; @@ -134,7 +133,7 @@ HELP // Handle --refresh option if ($input->getOption('refresh')) { $io->text('Refreshing version information...'); - $this->updateChecker->refreshGitInfo(); + $this->updateChecker->refreshVersionInfo(); $io->success('Version cache cleared.'); } diff --git a/src/Command/VersionCommand.php b/src/Command/VersionCommand.php index d2ce75e1..d09def8f 100644 --- a/src/Command/VersionCommand.php +++ b/src/Command/VersionCommand.php @@ -22,9 +22,9 @@ declare(strict_types=1); */ namespace App\Command; -use Symfony\Component\Console\Attribute\AsCommand; -use App\Services\Misc\GitVersionInfo; +use App\Services\System\GitVersionInfoProvider; use Shivas\VersioningBundle\Service\VersionManagerInterface; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -33,7 +33,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; #[AsCommand('partdb:version|app:version', 'Shows the currently installed version of Part-DB.')] class VersionCommand extends Command { - public function __construct(protected VersionManagerInterface $versionManager, protected GitVersionInfo $gitVersionInfo) + public function __construct(protected VersionManagerInterface $versionManager, protected GitVersionInfoProvider $gitVersionInfo) { parent::__construct(); } @@ -48,9 +48,9 @@ class VersionCommand extends Command $message = 'Part-DB version: '. $this->versionManager->getVersion()->toString(); - if ($this->gitVersionInfo->getGitBranchName() !== null) { - $message .= ' Git branch: '. $this->gitVersionInfo->getGitBranchName(); - $message .= ', Git commit: '. $this->gitVersionInfo->getGitCommitHash(); + if ($this->gitVersionInfo->getBranchName() !== null) { + $message .= ' Git branch: '. $this->gitVersionInfo->getBranchName(); + $message .= ', Git commit: '. $this->gitVersionInfo->getCommitHash(); } $io->success($message); diff --git a/src/Controller/HomepageController.php b/src/Controller/HomepageController.php index 076e790b..6192c249 100644 --- a/src/Controller/HomepageController.php +++ b/src/Controller/HomepageController.php @@ -24,8 +24,8 @@ 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\UpdateAvailableManager; use Doctrine\ORM\EntityManagerInterface; use Omines\DataTablesBundle\DataTableFactory; @@ -43,7 +43,7 @@ class HomepageController extends AbstractController #[Route(path: '/', name: 'homepage')] - public function homepage(Request $request, GitVersionInfo $versionInfo, EntityManagerInterface $entityManager, + public function homepage(Request $request, GitVersionInfoProvider $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->getGitBranchName(), - 'git_commit' => $versionInfo->getGitCommitHash(), + 'git_branch' => $versionInfo->getBranchName(), + 'git_commit' => $versionInfo->getCommitHash(), 'show_first_steps' => $show_first_steps, 'datatable' => $table, 'new_version_available' => $updateAvailableManager->isUpdateAvailable(), diff --git a/src/Controller/ToolsController.php b/src/Controller/ToolsController.php index d78aff62..f1ed888c 100644 --- a/src/Controller/ToolsController.php +++ b/src/Controller/ToolsController.php @@ -27,7 +27,7 @@ use App\Services\Attachments\AttachmentURLGenerator; use App\Services\Attachments\BuiltinAttachmentsFinder; use App\Services\Doctrine\DBInfoHelper; use App\Services\Doctrine\NatsortDebugHelper; -use App\Services\Misc\GitVersionInfo; +use App\Services\System\GitVersionInfoProvider; use App\Services\System\UpdateAvailableManager; use App\Settings\AppSettings; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -47,7 +47,7 @@ class ToolsController extends AbstractController } #[Route(path: '/server_infos', name: 'tools_server_infos')] - public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper, + public function systemInfos(GitVersionInfoProvider $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper, AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager, AppSettings $settings): Response { @@ -55,8 +55,8 @@ class ToolsController extends AbstractController return $this->render('tools/server_infos/server_infos.html.twig', [ //Part-DB section - 'git_branch' => $versionInfo->getGitBranchName(), - 'git_commit' => $versionInfo->getGitCommitHash(), + 'git_branch' => $versionInfo->getBranchName(), + 'git_commit' => $versionInfo->getCommitHash(), 'default_locale' => $settings->system->localization->locale, 'default_timezone' => $settings->system->localization->timezone, 'default_currency' => $settings->system->localization->baseCurrency, diff --git a/src/Controller/UpdateManagerController.php b/src/Controller/UpdateManagerController.php index b247cb38..08f7c77f 100644 --- a/src/Controller/UpdateManagerController.php +++ b/src/Controller/UpdateManagerController.php @@ -23,6 +23,7 @@ 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; @@ -47,6 +48,7 @@ class UpdateManagerController extends AbstractController 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')] @@ -96,7 +98,7 @@ class UpdateManagerController extends AbstractController 'is_maintenance' => $this->updateExecutor->isMaintenanceMode(), 'maintenance_info' => $this->updateExecutor->getMaintenanceInfo(), 'update_logs' => $this->updateExecutor->getUpdateLogs(), - 'backups' => $this->updateExecutor->getBackups(), + 'backups' => $this->backupManager->getBackups(), 'web_updates_disabled' => $this->webUpdatesDisabled, 'backup_restore_disabled' => $this->backupRestoreDisabled, ]); @@ -131,7 +133,7 @@ class UpdateManagerController extends AbstractController return $this->json(['error' => 'Invalid CSRF token'], Response::HTTP_FORBIDDEN); } - $this->updateChecker->refreshGitInfo(); + $this->updateChecker->refreshVersionInfo(); return $this->json([ 'success' => true, @@ -173,7 +175,7 @@ class UpdateManagerController extends AbstractController #[Route('/log/{filename}', name: 'admin_update_manager_log', methods: ['GET'])] public function viewLog(string $filename): Response { - $this->denyAccessUnlessGranted('@system.show_updates'); + $this->denyAccessUnlessGranted('@system.manage_updates'); // Security: Only allow viewing files from the update logs directory $logs = $this->updateExecutor->getUpdateLogs(); @@ -303,7 +305,7 @@ class UpdateManagerController extends AbstractController { $this->denyAccessUnlessGranted('@system.manage_updates'); - $details = $this->updateExecutor->getBackupDetails($filename); + $details = $this->backupManager->getBackupDetails($filename); if (!$details) { return $this->json(['error' => 'Backup not found'], 404); @@ -344,7 +346,7 @@ class UpdateManagerController extends AbstractController } // Verify the backup exists - $backupDetails = $this->updateExecutor->getBackupDetails($filename); + $backupDetails = $this->backupManager->getBackupDetails($filename); if (!$backupDetails) { $this->addFlash('error', 'Backup file not found.'); return $this->redirectToRoute('admin_update_manager'); diff --git a/src/Services/Misc/GitVersionInfo.php b/src/Services/Misc/GitVersionInfo.php deleted file mode 100644 index 3c079f4f..00000000 --- a/src/Services/Misc/GitVersionInfo.php +++ /dev/null @@ -1,83 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Services\Misc; - -use Symfony\Component\HttpKernel\KernelInterface; - -class GitVersionInfo -{ - protected string $project_dir; - - public function __construct(KernelInterface $kernel) - { - $this->project_dir = $kernel->getProjectDir(); - } - - /** - * Get the Git branch name of the installed system. - * - * @return string|null The current git branch name. Null, if this is no Git installation - */ - public function getGitBranchName(): ?string - { - if (is_file($this->project_dir.'/.git/HEAD')) { - $git = file($this->project_dir.'/.git/HEAD'); - $head = explode('/', $git[0], 3); - - if (!isset($head[2])) { - return null; - } - - return trim($head[2]); - } - - return null; // this is not a Git installation - } - - /** - * Get hash of the last git commit (on remote "origin"!). - * - * If this method does not work, try to make a "git pull" first! - * - * @param int $length if this is smaller than 40, only the first $length characters will be returned - * - * @return string|null The hash of the last commit, null If this is no Git installation - */ - public function getGitCommitHash(int $length = 7): ?string - { - $filename = $this->project_dir.'/.git/refs/remotes/origin/'.$this->getGitBranchName(); - if (is_file($filename)) { - $head = file($filename); - - if (!isset($head[0])) { - return null; - } - - $hash = $head[0]; - - return substr($hash, 0, $length); - } - - return null; // this is not a Git installation - } -} diff --git a/src/Services/System/GitVersionInfoProvider.php b/src/Services/System/GitVersionInfoProvider.php new file mode 100644 index 00000000..6d067333 --- /dev/null +++ b/src/Services/System/GitVersionInfoProvider.php @@ -0,0 +1,135 @@ +. + */ + +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 + { + $filename = $this->getGitDirectory() . '/refs/remotes/origin/'.$this->getBranchName(); + 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 + } + + /** + * Get the Git remote URL of the installed system. + */ + public function getRemoteURL(): ?string + { + // Get remote URL + $configFile = $this->getGitDirectory() . '/config'; + if (file_exists($configFile)) { + $config = file_get_contents($configFile); + if (preg_match('#url = (.+)#', $config, $matches)) { + return trim($matches[1]); + } + } + + return null; // this is not a Git installation + } + + /** + * Check if there are local changes in the Git repository. + * Attention: This runs a git command, which might be slow! + * @return bool|null True if there are local changes, false if not, null if this is not a Git installation + */ + public function hasLocalChanges(): ?bool + { + $process = new Process(['git', 'status', '--porcelain'], $this->project_dir); + $process->run(); + if (!$process->isSuccessful()) { + return null; // this is not a Git installation + } + return !empty(trim($process->getOutput())); + } +} diff --git a/src/Services/System/InstallationType.php b/src/Services/System/InstallationType.php new file mode 100644 index 00000000..74479bb9 --- /dev/null +++ b/src/Services/System/InstallationType.php @@ -0,0 +1,65 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\System; + +/** + * Detects the installation type of Part-DB to determine the appropriate update strategy. + */ +enum InstallationType: string +{ + case GIT = 'git'; + case DOCKER = 'docker'; + case ZIP_RELEASE = 'zip_release'; + case UNKNOWN = 'unknown'; + + public function getLabel(): string + { + return match ($this) { + self::GIT => 'Git Clone', + self::DOCKER => 'Docker', + self::ZIP_RELEASE => 'Release Archive (ZIP File)', + self::UNKNOWN => 'Unknown', + }; + } + + public function supportsAutoUpdate(): bool + { + return match ($this) { + self::GIT => true, + self::DOCKER => false, + // ZIP_RELEASE auto-update not yet implemented + self::ZIP_RELEASE => false, + self::UNKNOWN => false, + }; + } + + public function getUpdateInstructions(): string + { + return match ($this) { + self::GIT => 'Run: php bin/console partdb:update', + self::DOCKER => 'Pull the new Docker image and recreate the container: docker-compose pull && docker-compose up -d', + self::ZIP_RELEASE => 'Download the new release ZIP from GitHub, extract it over your installation, and run: php bin/console doctrine:migrations:migrate && php bin/console cache:clear', + self::UNKNOWN => 'Unable to determine installation type. Please update manually.', + }; + } +} diff --git a/src/Services/System/InstallationTypeDetector.php b/src/Services/System/InstallationTypeDetector.php index 4d04c55b..9f9fbdb8 100644 --- a/src/Services/System/InstallationTypeDetector.php +++ b/src/Services/System/InstallationTypeDetector.php @@ -26,51 +26,9 @@ namespace App\Services\System; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Process\Process; -/** - * Detects the installation type of Part-DB to determine the appropriate update strategy. - */ -enum InstallationType: string +readonly class InstallationTypeDetector { - 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', - 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.', - }; - } -} - -class InstallationTypeDetector -{ - public function __construct(#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir) + public function __construct(#[Autowire(param: 'kernel.project_dir')] private string $project_dir, private GitVersionInfoProvider $gitVersionInfoProvider) { } @@ -129,7 +87,7 @@ class InstallationTypeDetector */ public function isGitInstall(): bool { - return is_dir($this->project_dir . '/.git'); + return $this->gitVersionInfoProvider->isGitRepo(); } /** @@ -169,51 +127,21 @@ class InstallationTypeDetector /** * Get Git-specific information. + * @return array{branch: string|null, commit: string|null, remote_url: string|null, has_local_changes: bool} */ private function getGitInfo(): array { - $info = [ - 'branch' => null, - 'commit' => null, - 'remote_url' => null, - 'has_local_changes' => false, + return [ + 'branch' => $this->gitVersionInfoProvider->getBranchName(), + 'commit' => $this->gitVersionInfoProvider->getCommitHash(8), + 'remote_url' => $this->gitVersionInfoProvider->getRemoteURL(), + 'has_local_changes' => $this->gitVersionInfoProvider->hasLocalChanges() ?? false, ]; - - // Get branch - $headFile = $this->project_dir . '/.git/HEAD'; - if (file_exists($headFile)) { - $head = file_get_contents($headFile); - if (preg_match('#ref: refs/heads/(.+)#', $head, $matches)) { - $info['branch'] = trim($matches[1]); - } - } - - // Get remote URL - $configFile = $this->project_dir . '/.git/config'; - if (file_exists($configFile)) { - $config = file_get_contents($configFile); - if (preg_match('#url = (.+)#', $config, $matches)) { - $info['remote_url'] = trim($matches[1]); - } - } - - // Get commit hash - $process = new Process(['git', 'rev-parse', '--short', 'HEAD'], $this->project_dir); - $process->run(); - if ($process->isSuccessful()) { - $info['commit'] = trim($process->getOutput()); - } - - // Check for local changes - $process = new Process(['git', 'status', '--porcelain'], $this->project_dir); - $process->run(); - $info['has_local_changes'] = !empty(trim($process->getOutput())); - - return $info; } /** * Get Docker-specific information. + * @return array{container_id: string|null, image: string|null} */ private function getDockerInfo(): array { diff --git a/src/Services/System/UpdateChecker.php b/src/Services/System/UpdateChecker.php index 49a132ee..b7a90296 100644 --- a/src/Services/System/UpdateChecker.php +++ b/src/Services/System/UpdateChecker.php @@ -48,6 +48,7 @@ class UpdateChecker 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) { @@ -84,34 +85,15 @@ class UpdateChecker 'is_git_install' => false, ]; - $gitDir = $this->project_dir . '/.git'; - - if (!is_dir($gitDir)) { + if (!$this->gitVersionInfoProvider->isGitRepo()) { return $info; } $info['is_git_install'] = true; - // Get branch from HEAD file - $headFile = $gitDir . '/HEAD'; - if (file_exists($headFile)) { - $head = file_get_contents($headFile); - if (preg_match('#ref: refs/heads/(.+)#', $head, $matches)) { - $info['branch'] = trim($matches[1]); - } - } - - // Get current commit - $process = new Process(['git', 'rev-parse', '--short', 'HEAD'], $this->project_dir); - $process->run(); - if ($process->isSuccessful()) { - $info['commit'] = trim($process->getOutput()); - } - - // Check for local changes - $process = new Process(['git', 'status', '--porcelain'], $this->project_dir); - $process->run(); - $info['has_local_changes'] = !empty(trim($process->getOutput())); + $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']) { @@ -151,7 +133,7 @@ class UpdateChecker /** * Force refresh git information by invalidating cache. */ - public function refreshGitInfo(): void + public function refreshVersionInfo(): void { $gitInfo = $this->getGitInfo(); if ($gitInfo['branch']) { diff --git a/src/State/PartDBInfoProvider.php b/src/State/PartDBInfoProvider.php index b3496cad..b29ef227 100644 --- a/src/State/PartDBInfoProvider.php +++ b/src/State/PartDBInfoProvider.php @@ -7,8 +7,8 @@ namespace App\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; use App\ApiResource\PartDBInfo; -use App\Services\Misc\GitVersionInfo; use App\Services\System\BannerHelper; +use App\Services\System\GitVersionInfoProvider; use App\Settings\SystemSettings\CustomizationSettings; use App\Settings\SystemSettings\LocalizationSettings; use Shivas\VersioningBundle\Service\VersionManagerInterface; @@ -17,7 +17,7 @@ class PartDBInfoProvider implements ProviderInterface { public function __construct(private readonly VersionManagerInterface $versionManager, - private readonly GitVersionInfo $gitVersionInfo, + private readonly GitVersionInfoProvider $gitVersionInfo, private readonly BannerHelper $bannerHelper, private readonly string $default_uri, private readonly LocalizationSettings $localizationSettings, @@ -31,8 +31,8 @@ class PartDBInfoProvider implements ProviderInterface { return new PartDBInfo( version: $this->versionManager->getVersion()->toString(), - git_branch: $this->gitVersionInfo->getGitBranchName(), - git_commit: $this->gitVersionInfo->getGitCommitHash(), + git_branch: $this->gitVersionInfo->getBranchName(), + git_commit: $this->gitVersionInfo->getCommitHash(), title: $this->customizationSettings->instanceName, banner: $this->bannerHelper->getBanner(), default_uri: $this->default_uri, From 68ff0721ce486502fe717d7aaf2cef6be76b892f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 18:44:44 +0100 Subject: [PATCH 49/78] Merged functionality from UpdateAvailableManager and UpdateChecker --- src/Command/UpdateCommand.php | 4 +- src/Controller/HomepageController.php | 4 +- src/Controller/ToolsController.php | 4 +- src/Controller/UpdateManagerController.php | 2 +- ...eManager.php => UpdateAvailableFacade.php} | 46 ++++--------------- src/Services/System/UpdateChecker.php | 27 ++++++----- src/Twig/UpdateExtension.php | 4 +- 7 files changed, 34 insertions(+), 57 deletions(-) rename src/Services/System/{UpdateAvailableManager.php => UpdateAvailableFacade.php} (67%) diff --git a/src/Command/UpdateCommand.php b/src/Command/UpdateCommand.php index 64fa2bad..ca6c8399 100644 --- a/src/Command/UpdateCommand.php +++ b/src/Command/UpdateCommand.php @@ -166,7 +166,7 @@ HELP $includePrerelease = $input->getOption('include-prerelease'); if (!$targetVersion) { - $latest = $this->updateChecker->getLatestRelease($includePrerelease); + $latest = $this->updateChecker->getLatestVersion($includePrerelease); if (!$latest) { $io->error('Could not determine the latest version. Please specify a version manually.'); return Command::FAILURE; @@ -175,7 +175,7 @@ HELP } // Validate target version - if (!$this->updateChecker->isNewerVersion($targetVersion)) { + if (!$this->updateChecker->isNewerVersionThanCurrent($targetVersion)) { $io->warning(sprintf( 'Version %s is not newer than the current version %s.', $targetVersion, diff --git a/src/Controller/HomepageController.php b/src/Controller/HomepageController.php index 6192c249..6f863a3c 100644 --- a/src/Controller/HomepageController.php +++ b/src/Controller/HomepageController.php @@ -26,7 +26,7 @@ use App\DataTables\LogDataTable; use App\Entity\Parts\Part; use App\Services\System\BannerHelper; use App\Services\System\GitVersionInfoProvider; -use App\Services\System\UpdateAvailableManager; +use App\Services\System\UpdateAvailableFacade; use Doctrine\ORM\EntityManagerInterface; use Omines\DataTablesBundle\DataTableFactory; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -44,7 +44,7 @@ class HomepageController extends AbstractController #[Route(path: '/', name: 'homepage')] public function homepage(Request $request, GitVersionInfoProvider $versionInfo, EntityManagerInterface $entityManager, - UpdateAvailableManager $updateAvailableManager): Response + UpdateAvailableFacade $updateAvailableManager): Response { $this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS'); diff --git a/src/Controller/ToolsController.php b/src/Controller/ToolsController.php index f1ed888c..76dffb4d 100644 --- a/src/Controller/ToolsController.php +++ b/src/Controller/ToolsController.php @@ -28,7 +28,7 @@ use App\Services\Attachments\BuiltinAttachmentsFinder; use App\Services\Doctrine\DBInfoHelper; use App\Services\Doctrine\NatsortDebugHelper; use App\Services\System\GitVersionInfoProvider; -use App\Services\System\UpdateAvailableManager; +use App\Services\System\UpdateAvailableFacade; use App\Settings\AppSettings; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; @@ -48,7 +48,7 @@ class ToolsController extends AbstractController #[Route(path: '/server_infos', name: 'tools_server_infos')] public function systemInfos(GitVersionInfoProvider $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper, - AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager, + AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableFacade $updateAvailableManager, AppSettings $settings): Response { $this->denyAccessUnlessGranted('@system.server_infos'); diff --git a/src/Controller/UpdateManagerController.php b/src/Controller/UpdateManagerController.php index 08f7c77f..d88cab5d 100644 --- a/src/Controller/UpdateManagerController.php +++ b/src/Controller/UpdateManagerController.php @@ -226,7 +226,7 @@ class UpdateManagerController extends AbstractController if (!$targetVersion) { // Get latest version if not specified - $latest = $this->updateChecker->getLatestRelease(); + $latest = $this->updateChecker->getLatestVersion(); if (!$latest) { $this->addFlash('error', 'Could not determine target version.'); return $this->redirectToRoute('admin_update_manager'); diff --git a/src/Services/System/UpdateAvailableManager.php b/src/Services/System/UpdateAvailableFacade.php similarity index 67% rename from src/Services/System/UpdateAvailableManager.php rename to src/Services/System/UpdateAvailableFacade.php index 82cfb84e..2a00321c 100644 --- a/src/Services/System/UpdateAvailableManager.php +++ b/src/Services/System/UpdateAvailableFacade.php @@ -35,17 +35,18 @@ use Version\Version; /** * This class checks if a new version of Part-DB is available. */ -class UpdateAvailableManager +class UpdateAvailableFacade { private const API_URL = 'https://api.github.com/repos/Part-DB/Part-DB-server/releases/latest'; private const CACHE_KEY = 'uam_latest_version'; private const CACHE_TTL = 60 * 60 * 24 * 2; // 2 day - public function __construct(private readonly HttpClientInterface $httpClient, - private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager, - private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger, - #[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode) + public function __construct( + private readonly CacheInterface $updateCache, + private readonly PrivacySettings $privacySettings, + private readonly UpdateChecker $updateChecker, + ) { } @@ -89,9 +90,7 @@ class UpdateAvailableManager } $latestVersion = $this->getLatestVersion(); - $currentVersion = $this->versionManager->getVersion(); - - return $latestVersion->isGreaterThan($currentVersion); + return $this->updateChecker->isNewerVersionThanCurrent($latestVersion); } /** @@ -111,34 +110,7 @@ class UpdateAvailableManager return $this->updateCache->get(self::CACHE_KEY, function (ItemInterface $item) { $item->expiresAfter(self::CACHE_TTL); - try { - $response = $this->httpClient->request('GET', self::API_URL); - $result = $response->toArray(); - $tag_name = $result['tag_name']; - - // Remove the leading 'v' from the tag name - $version = substr($tag_name, 1); - - return [ - 'version' => $version, - 'url' => $result['html_url'], - ]; - } catch (\Exception $e) { - //When we are in dev mode, throw the exception, otherwise just silently log it - if ($this->is_dev_mode) { - throw $e; - } - - //In the case of an error, try it again after half of the cache time - $item->expiresAfter(self::CACHE_TTL / 2); - - $this->logger->error('Checking for updates failed: ' . $e->getMessage()); - - return [ - 'version' => '0.0.1', - 'url' => 'update-checking-error' - ]; - } + return $this->updateChecker->getLatestVersion(); }); } -} \ No newline at end of file +} diff --git a/src/Services/System/UpdateChecker.php b/src/Services/System/UpdateChecker.php index b7a90296..e388d51f 100644 --- a/src/Services/System/UpdateChecker.php +++ b/src/Services/System/UpdateChecker.php @@ -75,7 +75,7 @@ class UpdateChecker * Get Git repository information. * @return array{branch: ?string, commit: ?string, has_local_changes: bool, commits_behind: int, is_git_install: bool} */ - public function getGitInfo(): array + private function getGitInfo(): array { $info = [ 'branch' => null, @@ -113,7 +113,7 @@ class UpdateChecker return 0; } - $cacheKey = self::CACHE_KEY_COMMITS . '_' . md5($branch); + $cacheKey = self::CACHE_KEY_COMMITS . '_' . hash('xxh3', $branch); return $this->updateCache->get($cacheKey, function (ItemInterface $item) use ($branch) { $item->expiresAfter(self::CACHE_TTL); @@ -135,9 +135,9 @@ class UpdateChecker */ public function refreshVersionInfo(): void { - $gitInfo = $this->getGitInfo(); - if ($gitInfo['branch']) { - $this->updateCache->delete(self::CACHE_KEY_COMMITS . '_' . md5($gitInfo['branch'])); + $gitBranch = $this->gitVersionInfoProvider->getBranchName(); + if ($gitBranch) { + $this->updateCache->delete(self::CACHE_KEY_COMMITS . '_' . hash('xxh3', $gitBranch)); } $this->updateCache->delete(self::CACHE_KEY_RELEASES); } @@ -150,7 +150,10 @@ class UpdateChecker public function getAvailableReleases(int $limit = 10): array { if (!$this->privacySettings->checkForUpdates) { - return []; + return [ //If we don't want to check for updates, we can return dummy data + 'version' => '0.0.1', + 'url' => 'update-checking-disabled' + ]; } return $this->updateCache->get(self::CACHE_KEY_RELEASES, function (ItemInterface $item) use ($limit) { @@ -212,7 +215,7 @@ class UpdateChecker * 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 getLatestRelease(bool $includePrerelease = false): ?array + public function getLatestVersion(bool $includePrerelease = false): ?array { $releases = $this->getAvailableReleases(); @@ -236,11 +239,13 @@ class UpdateChecker /** * Check if a specific version is newer than current. */ - public function isNewerVersion(string $version): bool + public function isNewerVersionThanCurrent(Version|string $version): bool { + if ($version instanceof Version) { + return $version->isGreaterThan($this->getCurrentVersion()); + } try { - $targetVersion = Version::fromString(ltrim($version, 'v')); - return $targetVersion->isGreaterThan($this->getCurrentVersion()); + return Version::fromString(ltrim($version, 'v'))->isGreaterThan($this->getCurrentVersion()); } catch (\Exception) { return false; } @@ -254,7 +259,7 @@ class UpdateChecker public function getUpdateStatus(): array { $current = $this->getCurrentVersion(); - $latest = $this->getLatestRelease(); + $latest = $this->getLatestVersion(); $gitInfo = $this->getGitInfo(); $installInfo = $this->installationTypeDetector->getInstallationInfo(); diff --git a/src/Twig/UpdateExtension.php b/src/Twig/UpdateExtension.php index 10264d12..ee3bb16c 100644 --- a/src/Twig/UpdateExtension.php +++ b/src/Twig/UpdateExtension.php @@ -23,7 +23,7 @@ declare(strict_types=1); namespace App\Twig; -use App\Services\System\UpdateAvailableManager; +use App\Services\System\UpdateAvailableFacade; use Symfony\Bundle\SecurityBundle\Security; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -33,7 +33,7 @@ use Twig\TwigFunction; */ final class UpdateExtension extends AbstractExtension { - public function __construct(private readonly UpdateAvailableManager $updateAvailableManager, + public function __construct(private readonly UpdateAvailableFacade $updateAvailableManager, private readonly Security $security) { From 1ccc3ad44018d3b5ab4012b7639ae21fdcf348f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 19:48:27 +0100 Subject: [PATCH 50/78] Extracted logic used by both BackupManager and UpdateExecutor to new service --- src/Services/System/BackupManager.php | 56 ++++-------------- src/Services/System/CommandRunHelper.php | 75 ++++++++++++++++++++++++ src/Services/System/UpdateExecutor.php | 33 +---------- 3 files changed, 89 insertions(+), 75 deletions(-) create mode 100644 src/Services/System/CommandRunHelper.php diff --git a/src/Services/System/BackupManager.php b/src/Services/System/BackupManager.php index b646e433..9bdc7f71 100644 --- a/src/Services/System/BackupManager.php +++ b/src/Services/System/BackupManager.php @@ -35,17 +35,18 @@ use Symfony\Component\Process\Process; * This service handles all backup-related operations and can be used * by the Update Manager, CLI commands, or other services. */ -class BackupManager +readonly class BackupManager { private const BACKUP_DIR = 'var/backups'; public function __construct( #[Autowire(param: 'kernel.project_dir')] - private readonly string $projectDir, - private readonly LoggerInterface $logger, - private readonly Filesystem $filesystem, - private readonly VersionManagerInterface $versionManager, - private readonly EntityManagerInterface $entityManager, + private string $projectDir, + private LoggerInterface $logger, + private Filesystem $filesystem, + private VersionManagerInterface $versionManager, + private EntityManagerInterface $entityManager, + private CommandRunHelper $commandRunHelper, ) { } @@ -90,7 +91,7 @@ class BackupManager $backupFile = $backupDir . '/' . $prefix . '-v' . $currentVersion . '-' . date('Y-m-d-His') . '.zip'; } - $this->runCommand([ + $this->commandRunHelper->runCommand([ 'php', 'bin/console', 'partdb:backup', '--full', '--overwrite', @@ -103,7 +104,7 @@ class BackupManager } /** - * Get list of backups. + * Get list of backups, that are available, sorted by date descending. * * @return array */ @@ -126,7 +127,7 @@ class BackupManager } // Sort by date descending - usort($backups, fn($a, $b) => $b['date'] <=> $a['date']); + usort($backups, static fn($a, $b) => $b['date'] <=> $a['date']); return $backups; } @@ -135,7 +136,7 @@ class BackupManager * Get details about a specific backup file. * * @param string $filename The backup filename - * @return array|null Backup details or null if not found + * @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 { @@ -449,39 +450,4 @@ class BackupManager $this->filesystem->mirror($uploads, $this->projectDir . '/uploads', null, ['override' => true]); } } - - /** - * Run a shell command with proper error handling. - */ - private function runCommand(array $command, string $description, int $timeout = 120): string - { - $process = new Process($command, $this->projectDir); - $process->setTimeout($timeout); - - // Set environment variables - $currentEnv = getenv(); - if (!is_array($currentEnv)) { - $currentEnv = []; - } - $env = array_merge($currentEnv, [ - 'HOME' => $this->projectDir, - 'COMPOSER_HOME' => $this->projectDir . '/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/CommandRunHelper.php b/src/Services/System/CommandRunHelper.php new file mode 100644 index 00000000..7a144d5f --- /dev/null +++ b/src/Services/System/CommandRunHelper.php @@ -0,0 +1,75 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\System; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Process\Process; + +class CommandRunHelper +{ + private UpdateExecutor $updateExecutor; + + 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/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php index d6bc4127..90cabf82 100644 --- a/src/Services/System/UpdateExecutor.php +++ b/src/Services/System/UpdateExecutor.php @@ -46,6 +46,7 @@ class UpdateExecutor private array $steps = []; private ?string $currentLogFile = null; + private CommandRunHelper $commandRunHelper; public function __construct( #[Autowire(param: 'kernel.project_dir')] @@ -58,6 +59,7 @@ class UpdateExecutor #[Autowire(param: 'app.debug_mode')] private readonly bool $debugMode = false, ) { + $this->commandRunHelper = new CommandRunHelper($this); } /** @@ -516,36 +518,7 @@ class UpdateExecutor */ private 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, - '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; + return $this->commandRunHelper->runCommand($command, $description, $timeout); } /** From 720c1e51e84b4bfbf5395eb7c731043c249811f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 20:28:17 +0100 Subject: [PATCH 51/78] Improved UpdateExecutor --- src/Services/System/UpdateExecutor.php | 53 ++++++++------------ tests/Services/System/UpdateExecutorTest.php | 6 --- 2 files changed, 20 insertions(+), 39 deletions(-) diff --git a/src/Services/System/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php index 90cabf82..9f73c63a 100644 --- a/src/Services/System/UpdateExecutor.php +++ b/src/Services/System/UpdateExecutor.php @@ -34,6 +34,8 @@ use Symfony\Component\Process\Process; * * 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 { @@ -46,7 +48,6 @@ class UpdateExecutor private array $steps = []; private ?string $currentLogFile = null; - private CommandRunHelper $commandRunHelper; public function __construct( #[Autowire(param: 'kernel.project_dir')] @@ -56,10 +57,10 @@ class UpdateExecutor private readonly InstallationTypeDetector $installationTypeDetector, private readonly VersionManagerInterface $versionManager, private readonly BackupManager $backupManager, + private readonly CommandRunHelper $commandRunHelper, #[Autowire(param: 'app.debug_mode')] private readonly bool $debugMode = false, ) { - $this->commandRunHelper = new CommandRunHelper($this); } /** @@ -75,14 +76,12 @@ class UpdateExecutor */ public function isLocked(): bool { - $lockFile = $this->project_dir . '/' . self::LOCK_FILE; - - if (!file_exists($lockFile)) { + // Check if lock is stale (older than 1 hour) + $lockData = $this->getLockInfo(); + if ($lockData === null) { return false; } - // Check if lock is stale (older than 1 hour) - $lockData = json_decode(file_get_contents($lockFile), true); if ($lockData && isset($lockData['started_at'])) { $startedAt = new \DateTime($lockData['started_at']); $now = new \DateTime(); @@ -100,7 +99,8 @@ class UpdateExecutor } /** - * Get lock information. + * Get lock information, or null if not locked. + * @return null|array{started_at: string, pid: int, user: string} */ public function getLockInfo(): ?array { @@ -110,7 +110,7 @@ class UpdateExecutor return null; } - return json_decode(file_get_contents($lockFile), true); + return json_decode(file_get_contents($lockFile), true, 512, JSON_THROW_ON_ERROR); } /** @@ -123,6 +123,7 @@ class UpdateExecutor /** * Get maintenance mode information. + * @return null|array{enabled_at: string, reason: string} */ public function getMaintenanceInfo(): ?array { @@ -132,7 +133,7 @@ class UpdateExecutor return null; } - return json_decode(file_get_contents($maintenanceFile), true); + return json_decode(file_get_contents($maintenanceFile), true, 512, JSON_THROW_ON_ERROR); } /** @@ -157,7 +158,7 @@ class UpdateExecutor 'user' => get_current_user(), ]; - $this->filesystem->dumpFile($lockFile, json_encode($lockData, JSON_PRETTY_PRINT)); + $this->filesystem->dumpFile($lockFile, json_encode($lockData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); return true; } @@ -191,7 +192,7 @@ class UpdateExecutor 'reason' => $reason, ]; - $this->filesystem->dumpFile($maintenanceFile, json_encode($data, JSON_PRETTY_PRINT)); + $this->filesystem->dumpFile($maintenanceFile, json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); } /** @@ -574,6 +575,7 @@ class UpdateExecutor /** * Get list of update log files. + * @return array{file: string, path: string, date: int, size: int}[] */ public function getUpdateLogs(): array { @@ -594,28 +596,11 @@ class UpdateExecutor } // Sort by date descending - usort($logs, fn($a, $b) => $b['date'] <=> $a['date']); + usort($logs, static fn($a, $b) => $b['date'] <=> $a['date']); return $logs; } - /** - * Get list of backups. - * @deprecated Use BackupManager::getBackups() directly - */ - public function getBackups(): array - { - return $this->backupManager->getBackups(); - } - - /** - * Get details about a specific backup file. - * @deprecated Use BackupManager::getBackupDetails() directly - */ - public function getBackupDetails(string $filename): ?array - { - return $this->backupManager->getBackupDetails($filename); - } /** * Restore from a backup file with maintenance mode and cache clearing. @@ -746,8 +731,9 @@ class UpdateExecutor /** * 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 */ - public function saveProgress(array $progress): void + private function saveProgress(array $progress): void { $progressFile = $this->getProgressFilePath(); $progressDir = dirname($progressFile); @@ -756,11 +742,12 @@ class UpdateExecutor $this->filesystem->mkdir($progressDir); } - $this->filesystem->dumpFile($progressFile, json_encode($progress, JSON_PRETTY_PRINT)); + $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 { @@ -770,7 +757,7 @@ class UpdateExecutor return null; } - $data = json_decode(file_get_contents($progressFile), true); + $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'])) { diff --git a/tests/Services/System/UpdateExecutorTest.php b/tests/Services/System/UpdateExecutorTest.php index 9b832f6c..851d060c 100644 --- a/tests/Services/System/UpdateExecutorTest.php +++ b/tests/Services/System/UpdateExecutorTest.php @@ -74,12 +74,6 @@ class UpdateExecutorTest extends KernelTestCase $this->assertIsArray($logs); } - public function testGetBackupsReturnsArray(): void - { - $backups = $this->updateExecutor->getBackups(); - - $this->assertIsArray($backups); - } public function testValidateUpdatePreconditionsReturnsProperStructure(): void { From 7a856bf6f1015c862395f822d558eacc623eddb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 20:37:02 +0100 Subject: [PATCH 52/78] Try to emulate nohup behavior on windows --- src/Services/System/UpdateExecutor.php | 29 ++++++++++++++++++-------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/Services/System/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php index 9f73c63a..6a40af6e 100644 --- a/src/Services/System/UpdateExecutor.php +++ b/src/Services/System/UpdateExecutor.php @@ -835,15 +835,26 @@ class UpdateExecutor $this->filesystem->mkdir($logDir, 0755); } - // 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) - ); + //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, From 2b94ff952c67a46d372450256f20958bf65b086c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 20:49:21 +0100 Subject: [PATCH 53/78] Use different symbol for update manager --- src/Services/Trees/ToolsTreeBuilder.php | 2 +- templates/admin/update_manager/index.html.twig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index 5356781b..6397e3af 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -329,7 +329,7 @@ class ToolsTreeBuilder $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-cloud-download-alt'); + ))->setIcon('fa-fw fa-treeview fa-solid fa-arrow-circle-up'); } return $nodes; diff --git a/templates/admin/update_manager/index.html.twig b/templates/admin/update_manager/index.html.twig index 24dfcc96..3968de93 100644 --- a/templates/admin/update_manager/index.html.twig +++ b/templates/admin/update_manager/index.html.twig @@ -3,7 +3,7 @@ {% block title %}Part-DB {% trans %}update_manager.title{% endtrans %}{% endblock %} {% block card_title %} - Part-DB {% trans %}update_manager.title{% endtrans %} + Part-DB {% trans %}update_manager.title{% endtrans %} {% endblock %} {% block card_content %} From 29a08d152a03467b98a277128c88964809d2af6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 20:52:42 +0100 Subject: [PATCH 54/78] Use version info from updateChecker to be consistent --- src/Services/System/UpdateAvailableFacade.php | 4 ---- src/Services/System/UpdateExecutor.php | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Services/System/UpdateAvailableFacade.php b/src/Services/System/UpdateAvailableFacade.php index 2a00321c..d9f18997 100644 --- a/src/Services/System/UpdateAvailableFacade.php +++ b/src/Services/System/UpdateAvailableFacade.php @@ -24,12 +24,8 @@ 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; /** diff --git a/src/Services/System/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php index 6a40af6e..1dfc3dc1 100644 --- a/src/Services/System/UpdateExecutor.php +++ b/src/Services/System/UpdateExecutor.php @@ -55,7 +55,7 @@ class UpdateExecutor private readonly LoggerInterface $logger, private readonly Filesystem $filesystem, private readonly InstallationTypeDetector $installationTypeDetector, - private readonly VersionManagerInterface $versionManager, + private readonly UpdateChecker $updateChecker, private readonly BackupManager $backupManager, private readonly CommandRunHelper $commandRunHelper, #[Autowire(param: 'app.debug_mode')] @@ -68,7 +68,7 @@ class UpdateExecutor */ private function getCurrentVersionString(): string { - return $this->versionManager->getVersion()->toString(); + return $this->updateChecker->getCurrentVersionString(); } /** From 883e3b271dc58f04d63d759f0cd6ecf42677f623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 21:02:08 +0100 Subject: [PATCH 55/78] Fixed git commit hash logic --- .../System/GitVersionInfoProvider.php | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/Services/System/GitVersionInfoProvider.php b/src/Services/System/GitVersionInfoProvider.php index 6d067333..01925ff8 100644 --- a/src/Services/System/GitVersionInfoProvider.php +++ b/src/Services/System/GitVersionInfoProvider.php @@ -85,20 +85,26 @@ final readonly class GitVersionInfoProvider */ public function getCommitHash(int $length = 8): ?string { - $filename = $this->getGitDirectory() . '/refs/remotes/origin/'.$this->getBranchName(); - if (is_file($filename)) { - $head = file($filename); - - if (!isset($head[0])) { - return null; - } - - $hash = $head[0]; - - return substr($hash, 0, $length); + $path = $this->getGitDirectory() . '/HEAD'; + if (!file_exists($path)) { + return null; } - return null; // this is not a Git installation + $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; + } /** From d06df4410d8ed60d600c7b1cb5afb6f3ab122107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 21:18:03 +0100 Subject: [PATCH 56/78] Disable the web updater and web backup restore for now This can become default, when there is more experience with the web updated --- .env | 4 ++-- VERSION | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env b/.env index 3196241b..1956f2fe 100644 --- a/.env +++ b/.env @@ -65,11 +65,11 @@ ERROR_PAGE_SHOW_HELP=1 # Set this to 1 to completely disable web-based updates, regardless of user permissions. # Use this if you prefer to manage updates through your own deployment process. -DISABLE_WEB_UPDATES=0 +DISABLE_WEB_UPDATES=1 # Set this to 1 to disable the backup restore feature from the web UI. # Restoring backups is a destructive operation that could cause data loss. -DISABLE_BACKUP_RESTORE=0 +DISABLE_BACKUP_RESTORE=1 ################################################################################### # SAML Single sign on-settings diff --git a/VERSION b/VERSION index 73462a5a..437459cd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.1 +2.5.0 From 0e5a73b6f435de2050259ddbb070567ad6c68744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 21:22:06 +0100 Subject: [PATCH 57/78] Add nonce to inline script in progress bar --- templates/admin/update_manager/progress.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/admin/update_manager/progress.html.twig b/templates/admin/update_manager/progress.html.twig index c45a4e78..597b8a9a 100644 --- a/templates/admin/update_manager/progress.html.twig +++ b/templates/admin/update_manager/progress.html.twig @@ -186,7 +186,7 @@
{# JavaScript refresh - more reliable than meta refresh #} - - - diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index b6537a5f..c9167bd4 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -14718,60 +14718,6 @@ Buerklin-API Authentication server: bytes - - - update_manager.maintenance.title - Maintenance - - - - - update_manager.maintenance.heading - Part-DB is Updating - - - - - update_manager.maintenance.description - We're installing updates to make Part-DB even better. This should only take a moment. - - - - - update_manager.maintenance.step_backup - Creating backup - - - - - update_manager.maintenance.step_download - Downloading updates - - - - - update_manager.maintenance.step_install - Installing files - - - - - update_manager.maintenance.step_migrate - Running migrations - - - - - update_manager.maintenance.step_cache - Clearing cache - - - - - update_manager.maintenance.auto_refresh - This page will refresh automatically when the update is complete. - - perm.system.manage_updates From 1a06432cec3dda5da3f052c2bf4ecd78e793afbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 22:16:26 +0100 Subject: [PATCH 64/78] Removed custom yes and no translations --- templates/admin/update_manager/index.html.twig | 8 ++++---- translations/messages.en.xlf | 12 ------------ 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/templates/admin/update_manager/index.html.twig b/templates/admin/update_manager/index.html.twig index 9b95637d..44b9f8c0 100644 --- a/templates/admin/update_manager/index.html.twig +++ b/templates/admin/update_manager/index.html.twig @@ -87,11 +87,11 @@ {% if status.git.has_local_changes %} - {% trans %}update_manager.yes{% endtrans %} + {% trans %}Yes{% endtrans %} {% else %} - {% trans %}update_manager.no{% endtrans %} + {% trans %}No{% endtrans %} {% endif %} @@ -102,11 +102,11 @@ {% if status.can_auto_update %} - {% trans %}update_manager.yes{% endtrans %} + {% trans %}Yes{% endtrans %} {% else %} - {% trans %}update_manager.no{% endtrans %} + {% trans %}No{% endtrans %} {% endif %} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index c9167bd4..7a7408c1 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -14442,18 +14442,6 @@ Buerklin-API Authentication server: For safety and reliability, updates should be performed via the command line interface. The update process will automatically create a backup, enable maintenance mode, and handle migrations. - - - update_manager.yes - Yes - - - - - update_manager.no - No - - update_manager.up_to_date From 9ca1834d9bde039c49ac57f6e75667bd79e9de28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 23:07:24 +0100 Subject: [PATCH 65/78] Removed unused translations --- translations/messages.en.xlf | 126 ----------------------------------- 1 file changed, 126 deletions(-) diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 7a7408c1..6f8250ad 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -14544,84 +14544,6 @@ Buerklin-API Authentication server: No backups found - - - update_manager.pre_update_checklist - Pre-Update Checklist - - - - - update_manager.before_updating - Before Updating - - - - - update_manager.checklist.requirements - All requirements met - - - - - update_manager.checklist.no_local_changes - No local modifications - - - - - update_manager.checklist.backup_created - Backup will be created automatically - - - - - update_manager.checklist.read_release_notes - Read release notes for breaking changes - - - - - update_manager.update_will - The Update Will - - - - - update_manager.will.backup - Create a full backup - - - - - update_manager.will.maintenance - Enable maintenance mode - - - - - update_manager.will.git - Pull latest code from Git - - - - - update_manager.will.composer - Update dependencies via Composer - - - - - update_manager.will.migrations - Run database migrations - - - - - update_manager.will.cache - Clear and rebuild cache - - update_manager.validation_issues @@ -14712,66 +14634,24 @@ Buerklin-API Authentication server: Manage Part-DB updates - - - update_manager.update_now - Update Now - - - - - update_manager.update_from_to - Update from %from% to %to% - - - - - update_manager.update_description - Click the button to start the update process. A backup will be created automatically and you can monitor the progress. - - - - - update_manager.start_update - Start Update - - update_manager.create_backup Create backup before updating (recommended) - - - update_manager.estimated_time - Update typically takes 2-5 minutes - - update_manager.confirm_update Are you sure you want to update Part-DB? A backup will be created before the update. - - - update_manager.starting - Starting... - - update_manager.already_up_to_date You are running the latest version of Part-DB. - - - update_manager.cli_alternative - Alternatively, you can update via the command line: - - update_manager.progress.title @@ -14868,12 +14748,6 @@ Buerklin-API Authentication server: Downgrade Progress - - - update_manager.progress.downgrading - Downgrading Part-DB... - - update_manager.progress.downgrading_to From a755287c3b867910a3f211deabb99e6e172a46e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 23:09:52 +0100 Subject: [PATCH 66/78] Make maintenance command available under partdb:maintenance-mode to make it more consistent with other hyphen command tools --- src/Command/MaintenanceModeCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/MaintenanceModeCommand.php b/src/Command/MaintenanceModeCommand.php index 37f59af1..7fdea97e 100644 --- a/src/Command/MaintenanceModeCommand.php +++ b/src/Command/MaintenanceModeCommand.php @@ -32,7 +32,7 @@ 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')] +#[AsCommand('partdb:maintenance-mode', 'Enable/disable maintenance mode and set a message')] class MaintenanceModeCommand extends Command { public function __construct( From cad5261aba8b123158ed7039e955f0a842254141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 2 Feb 2026 23:26:18 +0100 Subject: [PATCH 67/78] Fixed phpstan issues --- phpstan.dist.neon | 6 ++++++ src/Command/MaintenanceModeCommand.php | 2 +- src/Services/System/CommandRunHelper.php | 2 -- src/Services/System/UpdateAvailableFacade.php | 2 -- src/Services/System/UpdateChecker.php | 7 ++----- src/Services/System/UpdateExecutor.php | 2 ++ 6 files changed, 11 insertions(+), 10 deletions(-) diff --git a/phpstan.dist.neon b/phpstan.dist.neon index fc7b3524..eb629314 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -6,6 +6,9 @@ parameters: - src # - tests + banned_code: + non_ignorable: false # Allow to ignore some banned code + excludePaths: - src/DataTables/Adapter/* - src/Configuration/* @@ -61,3 +64,6 @@ parameters: # Ignore error of unused WithPermPresetsTrait, as it is used in the migrations which are not analyzed by Phpstan - '#Trait App\\Migration\\WithPermPresetsTrait is used zero times and is not analysed#' + - + message: '#Should not use function "shell_exec"#' + path: src/Services/System/UpdateExecutor.php diff --git a/src/Command/MaintenanceModeCommand.php b/src/Command/MaintenanceModeCommand.php index 7fdea97e..47b1eaef 100644 --- a/src/Command/MaintenanceModeCommand.php +++ b/src/Command/MaintenanceModeCommand.php @@ -106,7 +106,7 @@ class MaintenanceModeCommand extends Command if ($enable) { // Use provided message or fallback to a default English message - $reason = is_string($message) && $message !== '' + $reason = is_string($message) ? $message : 'The system is temporarily unavailable due to maintenance.'; diff --git a/src/Services/System/CommandRunHelper.php b/src/Services/System/CommandRunHelper.php index 7a144d5f..19bc8548 100644 --- a/src/Services/System/CommandRunHelper.php +++ b/src/Services/System/CommandRunHelper.php @@ -28,8 +28,6 @@ use Symfony\Component\Process\Process; class CommandRunHelper { - private UpdateExecutor $updateExecutor; - public function __construct( #[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir ) diff --git a/src/Services/System/UpdateAvailableFacade.php b/src/Services/System/UpdateAvailableFacade.php index d9f18997..ac3a46c0 100644 --- a/src/Services/System/UpdateAvailableFacade.php +++ b/src/Services/System/UpdateAvailableFacade.php @@ -33,8 +33,6 @@ use Version\Version; */ class UpdateAvailableFacade { - - private const API_URL = 'https://api.github.com/repos/Part-DB/Part-DB-server/releases/latest'; private const CACHE_KEY = 'uam_latest_version'; private const CACHE_TTL = 60 * 60 * 24 * 2; // 2 day diff --git a/src/Services/System/UpdateChecker.php b/src/Services/System/UpdateChecker.php index e388d51f..fdb8d9dd 100644 --- a/src/Services/System/UpdateChecker.php +++ b/src/Services/System/UpdateChecker.php @@ -145,15 +145,12 @@ class UpdateChecker /** * Get all available releases from GitHub (cached). * - * @return array + * @return array */ public function getAvailableReleases(int $limit = 10): array { if (!$this->privacySettings->checkForUpdates) { - return [ //If we don't want to check for updates, we can return dummy data - 'version' => '0.0.1', - 'url' => 'update-checking-disabled' - ]; + return []; } return $this->updateCache->get(self::CACHE_KEY_RELEASES, function (ItemInterface $item) use ($limit) { diff --git a/src/Services/System/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php index 1dfc3dc1..2fe54173 100644 --- a/src/Services/System/UpdateExecutor.php +++ b/src/Services/System/UpdateExecutor.php @@ -863,6 +863,8 @@ class UpdateExecutor // 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 From 984529bc7940ef30f62469c6b944c396469c75f0 Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:55:53 +0100 Subject: [PATCH 68/78] Add Update Manager documentation - Add comprehensive update_manager.md with feature overview - Document CLI commands (partdb:update, partdb:maintenance-mode) - Document web interface and permissions - Add security considerations and troubleshooting - Update console_commands.md with new commands --- docs/usage/console_commands.md | 8 ++ docs/usage/update_manager.md | 170 +++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 docs/usage/update_manager.md diff --git a/docs/usage/console_commands.md b/docs/usage/console_commands.md index b42bb757..576b3314 100644 --- a/docs/usage/console_commands.md +++ b/docs/usage/console_commands.md @@ -50,6 +50,14 @@ docker exec --user=www-data partdb php bin/console cache:clear * `php bin/console partdb:currencies:update-exchange-rates`: Update the exchange rates of all currencies from the internet +## Update Manager commands + +{: .note } +> The Update Manager is an experimental feature. See the [Update Manager documentation](update_manager.md) for details. + +* `php bin/console partdb:update`: Check for and perform updates to Part-DB. Use `--check` to only check for updates without installing. +* `php bin/console partdb:maintenance-mode`: Enable, disable, or check the status of maintenance mode. Use `--enable`, `--disable`, or `--status`. + ## Installation/Maintenance commands * `php bin/console partdb:backup`: Backup the database and the attachments diff --git a/docs/usage/update_manager.md b/docs/usage/update_manager.md new file mode 100644 index 00000000..43fe2c94 --- /dev/null +++ b/docs/usage/update_manager.md @@ -0,0 +1,170 @@ +--- +title: Update Manager +layout: default +parent: Usage +--- + +# Update Manager (Experimental) + +{: .warning } +> The Update Manager is currently an **experimental feature**. It is disabled by default while user experience data is being gathered. Use with caution and always ensure you have proper backups before updating. + +Part-DB includes an Update Manager that can automatically update Git-based installations to newer versions. The Update Manager provides both a web interface and CLI commands for managing updates, backups, and maintenance mode. + +## Supported Installation Types + +The Update Manager currently supports automatic updates only for **Git clone** installations. Other installation types show manual update instructions: + +| Installation Type | Auto-Update | Instructions | +|-------------------|-------------|--------------| +| Git Clone | Yes | Automatic via CLI or Web UI | +| Docker | No | Pull new image: `docker-compose pull && docker-compose up -d` | +| ZIP Release | No | Download and extract new release manually | + +## Enabling the Update Manager + +By default, web-based updates and backup restore are **disabled** for security reasons. To enable them, add these settings to your `.env.local` file: + +```bash +# Enable web-based updates (default: disabled) +DISABLE_WEB_UPDATES=0 + +# Enable backup restore via web interface (default: disabled) +DISABLE_BACKUP_RESTORE=0 +``` + +{: .note } +> Even with web updates disabled, you can still use the CLI commands to perform updates. + +## CLI Commands + +### Update Command + +Check for updates or perform an update: + +```bash +# Check for available updates +php bin/console partdb:update --check + +# Update to the latest version +php bin/console partdb:update + +# Update to a specific version +php bin/console partdb:update v2.6.0 + +# Update without creating a backup first +php bin/console partdb:update --no-backup + +# Force update without confirmation prompt +php bin/console partdb:update --force +``` + +### Maintenance Mode Command + +Manually enable or disable maintenance mode: + +```bash +# Enable maintenance mode with default message +php bin/console partdb:maintenance-mode --enable + +# Enable with custom message +php bin/console partdb:maintenance-mode --enable "System maintenance until 6 PM" +php bin/console partdb:maintenance-mode --enable --message="Updating to v2.6.0" + +# Disable maintenance mode +php bin/console partdb:maintenance-mode --disable + +# Check current status +php bin/console partdb:maintenance-mode --status +``` + +## Web Interface + +When web updates are enabled, the Update Manager is accessible at **System > Update Manager** (URL: `/system/update-manager`). + +The web interface shows: +- Current version and installation type +- Available updates with release notes +- Precondition validation (Git, Composer, Yarn, permissions) +- Update history and logs +- Backup management + +### Required Permissions + +Users need the following permissions to access the Update Manager: + +| Permission | Description | +|------------|-------------| +| `@system.show_updates` | View update status and available versions | +| `@system.manage_updates` | Perform updates and restore backups | + +## Update Process + +When an update is performed, the following steps are executed: + +1. **Lock** - Acquire exclusive lock to prevent concurrent updates +2. **Maintenance Mode** - Enable maintenance mode to block user access +3. **Rollback Tag** - Create a Git tag for potential rollback +4. **Backup** - Create a full backup (optional but recommended) +5. **Git Fetch** - Fetch latest changes from origin +6. **Git Checkout** - Checkout the target version +7. **Composer Install** - Install/update PHP dependencies +8. **Yarn Install** - Install frontend dependencies +9. **Yarn Build** - Compile frontend assets +10. **Database Migrations** - Run any new migrations +11. **Cache Clear** - Clear the application cache +12. **Cache Warmup** - Rebuild the cache +13. **Maintenance Off** - Disable maintenance mode +14. **Unlock** - Release the update lock + +If any step fails, the system automatically attempts to rollback to the previous version. + +## Backup Management + +The Update Manager automatically creates backups before updates. These backups are stored in `var/backups/` and include: + +- Database dump (SQL file or SQLite database) +- Configuration files (`.env.local`, `parameters.yaml`, `banner.md`) +- Attachment files (`uploads/`, `public/media/`) + +### Restoring from Backup + +{: .warning } +> Backup restore is a destructive operation that will overwrite your current database. Only use this if you need to recover from a failed update. + +If web restore is enabled (`DISABLE_BACKUP_RESTORE=0`), you can restore backups from the web interface. The restore process: + +1. Enables maintenance mode +2. Extracts the backup +3. Restores the database +4. Optionally restores config and attachments +5. Clears and warms up the cache +6. Disables maintenance mode + +## Troubleshooting + +### Precondition Errors + +Before updating, the system validates: + +- **Git available**: Git must be installed and in PATH +- **No local changes**: Uncommitted changes must be committed or stashed +- **Composer available**: Composer must be installed and in PATH +- **Yarn available**: Yarn must be installed and in PATH +- **Write permissions**: `var/`, `vendor/`, and `public/` must be writable +- **Not already locked**: No other update can be in progress + +### Stale Lock + +If an update was interrupted and the lock file remains, it will automatically be removed after 1 hour. You can also manually delete `var/update.lock`. + +### Viewing Update Logs + +Update logs are stored in `var/log/updates/` and can be viewed from the web interface or directly on the server. + +## Security Considerations + +- **Disable web updates in production** unless you specifically need them +- The Update Manager requires shell access to run Git, Composer, and Yarn +- Backup files may contain sensitive data (database, config) - secure the `var/backups/` directory +- Consider running updates during maintenance windows with low user activity From e83e7398a27ad68bc40b9d8c753783af49bec61d Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:16:24 +0100 Subject: [PATCH 69/78] Improve .env comments for Update Manager settings Clarify that 0=enabled and 1=disabled for DISABLE_WEB_UPDATES and DISABLE_BACKUP_RESTORE environment variables. --- .env | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.env b/.env index 1956f2fe..3ba3d65d 100644 --- a/.env +++ b/.env @@ -63,12 +63,12 @@ ERROR_PAGE_SHOW_HELP=1 # Update Manager settings ################################################################################### -# Set this to 1 to completely disable web-based updates, regardless of user permissions. -# Use this if you prefer to manage updates through your own deployment process. +# 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 -# Set this to 1 to disable the backup restore feature from the web UI. -# Restoring backups is a destructive operation that could cause data loss. +# 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 ################################################################################### From c34acfe5239befc90e82fe21a63f6437b3bae768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 3 Feb 2026 20:34:03 +0100 Subject: [PATCH 70/78] Allow to view progress view while update is running --- src/EventSubscriber/MaintenanceModeSubscriber.php | 5 +++++ templates/admin/update_manager/progress.html.twig | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/EventSubscriber/MaintenanceModeSubscriber.php b/src/EventSubscriber/MaintenanceModeSubscriber.php index 6efa975e..654ba9f2 100644 --- a/src/EventSubscriber/MaintenanceModeSubscriber.php +++ b/src/EventSubscriber/MaintenanceModeSubscriber.php @@ -62,6 +62,11 @@ readonly class MaintenanceModeSubscriber implements EventSubscriberInterface 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; diff --git a/templates/admin/update_manager/progress.html.twig b/templates/admin/update_manager/progress.html.twig index 597b8a9a..54ac6595 100644 --- a/templates/admin/update_manager/progress.html.twig +++ b/templates/admin/update_manager/progress.html.twig @@ -29,7 +29,7 @@ {{ parent() }} {# Auto-refresh while update is running - also refresh when 'starting' status #} {% if not progress or progress.status == 'running' or progress.status == 'starting' %} - + {% endif %} {% endblock %} @@ -189,7 +189,7 @@ {% endif %} From 5ceadc8353648aed1b588c2d7c0cd1b3ab42412d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 3 Feb 2026 20:49:25 +0100 Subject: [PATCH 71/78] Use a special settings cache that lives in cache.system to ensure that it is properly cleared on cache clear --- config/packages/cache.yaml | 4 ++++ config/packages/settings.yaml | 1 + 2 files changed, 5 insertions(+) diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 6adea442..c1816aa2 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -23,3 +23,7 @@ framework: info_provider.cache: adapter: cache.app + + cache.settings: + adapter: cache.system + tags: true diff --git a/config/packages/settings.yaml b/config/packages/settings.yaml index c16d1804..b3d209f6 100644 --- a/config/packages/settings.yaml +++ b/config/packages/settings.yaml @@ -3,6 +3,7 @@ jbtronics_settings: cache: default_cacheable: true + service: 'cache.settings' orm_storage: default_entity_class: App\Entity\SettingsEntry From 1601382b41275c715c71b508505042c7ecbe893f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 3 Feb 2026 20:55:31 +0100 Subject: [PATCH 72/78] Added translation for downgrading in progress title --- translations/messages.en.xlf | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 6f8250ad..89b03824 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -14688,6 +14688,12 @@ Buerklin-API Authentication server: Updating to version %version% + + + update_manager.progress.downgrading_to + Downgrading to version %version% + + update_manager.progress.error @@ -14748,12 +14754,6 @@ Buerklin-API Authentication server: Downgrade Progress - - - update_manager.progress.downgrading_to - Downgrading to version %version% - - update_manager.progress.downgrade_completed From bc28eb947319b8eddcc2a91bb91cd2e232f6b0bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 3 Feb 2026 21:42:50 +0100 Subject: [PATCH 73/78] Remove lowercase version of Makefile that causes warnings on Windows --- makefile | 91 -------------------------------------------------------- 1 file changed, 91 deletions(-) delete mode 100644 makefile diff --git a/makefile b/makefile deleted file mode 100644 index bc4d0bf3..00000000 --- a/makefile +++ /dev/null @@ -1,91 +0,0 @@ -# PartDB Makefile for Test Environment Management - -.PHONY: help deps-install lint format format-check test coverage pre-commit all test-typecheck \ -test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run test-reset \ -section-dev dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset - -# Default target -help: ## Show this help - @awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) - -# Dependencies -deps-install: ## Install PHP dependencies with unlimited memory - @echo "📦 Installing PHP dependencies..." - COMPOSER_MEMORY_LIMIT=-1 composer install - yarn install - @echo "✅ Dependencies installed" - -# Complete test environment setup -test-setup: test-clean test-db-create test-db-migrate test-fixtures ## Complete test setup (clean, create DB, migrate, fixtures) - @echo "✅ Test environment setup complete!" - -# Clean test environment -test-clean: ## Clean test cache and database files - @echo "🧹 Cleaning test environment..." - rm -rf var/cache/test - rm -f var/app_test.db - @echo "✅ Test environment cleaned" - -# Create test database -test-db-create: ## Create test database (if not exists) - @echo "🗄️ Creating test database..." - -php bin/console doctrine:database:create --if-not-exists --env test || echo "⚠️ Database creation failed (expected for SQLite) - continuing..." - -# Run database migrations for test environment -test-db-migrate: ## Run database migrations for test environment - @echo "🔄 Running database migrations..." - COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test - -# Clear test cache -test-cache-clear: ## Clear test cache - @echo "🗑️ Clearing test cache..." - rm -rf var/cache/test - @echo "✅ Test cache cleared" - -# Load test fixtures -test-fixtures: ## Load test fixtures - @echo "📦 Loading test fixtures..." - php bin/console partdb:fixtures:load -n --env test - -# Run PHPUnit tests -test-run: ## Run PHPUnit tests - @echo "🧪 Running tests..." - php bin/phpunit - -# Quick test reset (clean + migrate + fixtures, skip DB creation) -test-reset: test-cache-clear test-db-migrate test-fixtures - @echo "✅ Test environment reset complete!" - -test-typecheck: ## Run static analysis (PHPStan) - @echo "🧪 Running type checks..." - COMPOSER_MEMORY_LIMIT=-1 composer phpstan - -# Development helpers -dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup ## Complete development setup (clean, create DB, migrate, warmup) - @echo "✅ Development environment setup complete!" - -dev-clean: ## Clean development cache and database files - @echo "🧹 Cleaning development environment..." - rm -rf var/cache/dev - rm -f var/app_dev.db - @echo "✅ Development environment cleaned" - -dev-db-create: ## Create development database (if not exists) - @echo "🗄️ Creating development database..." - -php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..." - -dev-db-migrate: ## Run database migrations for development environment - @echo "🔄 Running database migrations..." - COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev - -dev-cache-clear: ## Clear development cache - @echo "🗑️ Clearing development cache..." - rm -rf var/cache/dev - @echo "✅ Development cache cleared" - -dev-warmup: ## Warm up development cache - @echo "🔥 Warming up development cache..." - COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n - -dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate) - @echo "✅ Development environment reset complete!" \ No newline at end of file From c027f9ab03cf760843744faea7c7d379396b3bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 3 Feb 2026 21:48:17 +0100 Subject: [PATCH 74/78] Updated dependencies --- composer.lock | 80 +++++++++++++++---------- config/reference.php | 1 + yarn.lock | 138 +++++++++++++++++++++---------------------- 3 files changed, 118 insertions(+), 101 deletions(-) diff --git a/composer.lock b/composer.lock index 2ee826f6..56ab8701 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ec69ea04bcf5c1ebd8bb0280a5bb9565", + "content-hash": "8e387d6d016f33eb7302c47ecb7a12b9", "packages": [ { "name": "amphp/amp", @@ -4997,16 +4997,16 @@ }, { "name": "jbtronics/settings-bundle", - "version": "v3.1.3", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/jbtronics/settings-bundle.git", - "reference": "a99c6e4cde40b829c1643b89da506b9588b11eaf" + "reference": "6a66c099460fd623d0d1ddbf9864b3173d416c3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jbtronics/settings-bundle/zipball/a99c6e4cde40b829c1643b89da506b9588b11eaf", - "reference": "a99c6e4cde40b829c1643b89da506b9588b11eaf", + "url": "https://api.github.com/repos/jbtronics/settings-bundle/zipball/6a66c099460fd623d0d1ddbf9864b3173d416c3b", + "reference": "6a66c099460fd623d0d1ddbf9864b3173d416c3b", "shasum": "" }, "require": { @@ -5067,7 +5067,7 @@ ], "support": { "issues": "https://github.com/jbtronics/settings-bundle/issues", - "source": "https://github.com/jbtronics/settings-bundle/tree/v3.1.3" + "source": "https://github.com/jbtronics/settings-bundle/tree/v3.2.0" }, "funding": [ { @@ -5079,7 +5079,7 @@ "type": "github" } ], - "time": "2026-01-02T23:58:02+00:00" + "time": "2026-02-03T20:13:02+00:00" }, { "name": "jfcherng/php-color-output", @@ -7191,16 +7191,16 @@ }, { "name": "nette/utils", - "version": "v4.1.1", + "version": "v4.1.2", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" + "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", - "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", + "url": "https://api.github.com/repos/nette/utils/zipball/f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", "shasum": "" }, "require": { @@ -7213,7 +7213,7 @@ "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", "nette/tester": "^2.5", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/phpstan": "^2.0@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -7274,9 +7274,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.1" + "source": "https://github.com/nette/utils/tree/v4.1.2" }, - "time": "2025-12-22T12:14:32+00:00" + "time": "2026-02-03T17:21:09+00:00" }, { "name": "nikolaposa/version", @@ -18611,28 +18611,28 @@ }, { "name": "phpunit/php-file-iterator", - "version": "5.1.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -18660,15 +18660,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2024-08-27T05:02:59+00:00" + "time": "2026-02-02T13:52:54+00:00" }, { "name": "phpunit/php-invoker", @@ -19029,12 +19041,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "8457f2008fc6396be788162c4e04228028306534" + "reference": "57534122edd70a2b3dbb02b65f2091efc57e4ab7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/8457f2008fc6396be788162c4e04228028306534", - "reference": "8457f2008fc6396be788162c4e04228028306534", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57534122edd70a2b3dbb02b65f2091efc57e4ab7", + "reference": "57534122edd70a2b3dbb02b65f2091efc57e4ab7", "shasum": "" }, "conflict": { @@ -19144,6 +19156,7 @@ "cesnet/simplesamlphp-module-proxystatistics": "<3.1", "chriskacerguis/codeigniter-restserver": "<=2.7.1", "chrome-php/chrome": "<1.14", + "ci4-cms-erp/ci4ms": "<0.28.5", "civicrm/civicrm-core": ">=4.2,<4.2.9|>=4.3,<4.3.3", "ckeditor/ckeditor": "<4.25", "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", @@ -19175,6 +19188,8 @@ "couleurcitron/tarteaucitron-wp": "<0.3", "cpsit/typo3-mailqueue": "<0.4.3|>=0.5,<0.5.1", "craftcms/cms": "<=4.16.16|>=5,<=5.8.20", + "craftcms/commerce": ">=4.0.0.0-RC1-dev,<=4.10|>=5,<=5.5.1", + "craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1", "croogo/croogo": "<=4.0.7", "cuyz/valinor": "<0.12", "czim/file-handling": "<1.5|>=2,<2.3", @@ -19192,7 +19207,7 @@ "derhansen/sf_event_mgt": "<4.3.1|>=5,<5.1.1|>=7,<7.4", "desperado/xml-bundle": "<=0.1.7", "dev-lancer/minecraft-motd-parser": "<=1.0.5", - "devcode-it/openstamanager": "<=2.9.4", + "devcode-it/openstamanager": "<=2.9.8", "devgroup/dotplant": "<2020.09.14-dev", "digimix/wp-svg-upload": "<=1", "directmailteam/direct-mail": "<6.0.3|>=7,<7.0.3|>=8,<9.5.2", @@ -19275,18 +19290,18 @@ "ezsystems/ezplatform-admin-ui-assets": ">=4,<4.2.1|>=5,<5.0.1|>=5.1,<5.1.1|>=5.3.0.0-beta1,<5.3.5", "ezsystems/ezplatform-graphql": ">=1.0.0.0-RC1-dev,<1.0.13|>=2.0.0.0-beta1,<2.3.12", "ezsystems/ezplatform-http-cache": "<2.3.16", - "ezsystems/ezplatform-kernel": "<1.2.5.1-dev|>=1.3,<1.3.35", + "ezsystems/ezplatform-kernel": "<=1.2.5|>=1.3,<1.3.35", "ezsystems/ezplatform-rest": ">=1.2,<=1.2.2|>=1.3,<1.3.8", "ezsystems/ezplatform-richtext": ">=2.3,<2.3.26|>=3.3,<3.3.40", "ezsystems/ezplatform-solr-search-engine": ">=1.7,<1.7.12|>=2,<2.0.2|>=3.3,<3.3.15", "ezsystems/ezplatform-user": ">=1,<1.0.1", - "ezsystems/ezpublish-kernel": "<6.13.8.2-dev|>=7,<7.5.31", + "ezsystems/ezpublish-kernel": "<=6.13.8.1|>=7,<7.5.31", "ezsystems/ezpublish-legacy": "<=2017.12.7.3|>=2018.6,<=2019.03.5.1", "ezsystems/platform-ui-assets-bundle": ">=4.2,<4.2.3", "ezsystems/repository-forms": ">=2.3,<2.3.2.1-dev|>=2.5,<2.5.15", "ezyang/htmlpurifier": "<=4.2", "facade/ignition": "<1.16.15|>=2,<2.4.2|>=2.5,<2.5.2", - "facturascripts/facturascripts": "<=2025.4|==2025.11|==2025.41|==2025.43", + "facturascripts/facturascripts": "<2025.81", "fastly/magento2": "<1.2.26", "feehi/cms": "<=2.1.1", "feehi/feehicms": "<=2.1.1", @@ -19575,7 +19590,7 @@ "open-web-analytics/open-web-analytics": "<1.8.1", "opencart/opencart": ">=0", "openid/php-openid": "<2.3", - "openmage/magento-lts": "<20.16", + "openmage/magento-lts": "<20.16.1", "opensolutions/vimbadmin": "<=3.0.15", "opensource-workshop/connect-cms": "<1.8.7|>=2,<2.4.7", "orchid/platform": ">=8,<14.43", @@ -20037,7 +20052,7 @@ "type": "tidelift" } ], - "time": "2026-01-30T22:06:58+00:00" + "time": "2026-02-03T19:20:38+00:00" }, { "name": "sebastian/cli-parser", @@ -21564,7 +21579,8 @@ "ext-iconv": "*", "ext-intl": "*", "ext-json": "*", - "ext-mbstring": "*" + "ext-mbstring": "*", + "ext-zip": "*" }, "platform-dev": {}, "platform-overrides": { diff --git a/config/reference.php b/config/reference.php index 82bdc45e..a1a077aa 100644 --- a/config/reference.php +++ b/config/reference.php @@ -2387,6 +2387,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * prefetch_all?: bool|Param, // Default: true * }, * cache?: array{ + * metadata_service?: scalar|Param|null, // Default: "cache.system" * service?: scalar|Param|null, // Default: "cache.app.taggable" * default_cacheable?: bool|Param, // Default: false * ttl?: int|Param, // Default: 0 diff --git a/yarn.lock b/yarn.lock index abbc7d9c..f3355f71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,58 +2,58 @@ # yarn lockfile v1 -"@algolia/autocomplete-core@1.19.4": - version "1.19.4" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-core/-/autocomplete-core-1.19.4.tgz#db9e4ef88cd8f2ce5b25e376373a8898dcbe2945" - integrity sha512-yVwXLrfwQ3dAndY12j1pfa0oyC5hTDv+/dgwvVHj57dY3zN6PbAmcHdV5DOOdGJrCMXff+fsPr8G2Ik8zWOPTw== +"@algolia/autocomplete-core@1.19.5": + version "1.19.5" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-core/-/autocomplete-core-1.19.5.tgz#52d99aafce19493161220e417071f0222eeea7d6" + integrity sha512-/kAE3mMBage/9m0OGnKQteSa7/eIfvhiKx28OWj857+dJ6qYepEBuw5L8its2oTX8ZNM/6TA3fo49kMwgcwjlg== dependencies: - "@algolia/autocomplete-plugin-algolia-insights" "1.19.4" - "@algolia/autocomplete-shared" "1.19.4" + "@algolia/autocomplete-plugin-algolia-insights" "1.19.5" + "@algolia/autocomplete-shared" "1.19.5" -"@algolia/autocomplete-js@1.19.4", "@algolia/autocomplete-js@^1.17.0": - version "1.19.4" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-js/-/autocomplete-js-1.19.4.tgz#235e554d4e46567d7305d8c216b75dd2a0091655" - integrity sha512-ZkwsuTTIEuw+hbsIooMrNLvTVulUSSKqJT3ZeYYd//kA5fHaFf2/T0BDmd9qSGxZRhT5WS8AJYjFARLmj5x08g== +"@algolia/autocomplete-js@1.19.5", "@algolia/autocomplete-js@^1.17.0": + version "1.19.5" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-js/-/autocomplete-js-1.19.5.tgz#2ec3efd9d5efd505ea677775d0199e1207e4624e" + integrity sha512-C2/bEQeqq4nZ4PH2rySRvU9B224KbiCXAPZIn3pmMII/7BiXkppPQyDd+Fdly3ubOmnGFDH6BTzGHamySeOYeg== dependencies: - "@algolia/autocomplete-core" "1.19.4" - "@algolia/autocomplete-preset-algolia" "1.19.4" - "@algolia/autocomplete-shared" "1.19.4" + "@algolia/autocomplete-core" "1.19.5" + "@algolia/autocomplete-preset-algolia" "1.19.5" + "@algolia/autocomplete-shared" "1.19.5" htm "^3.1.1" preact "^10.13.2" -"@algolia/autocomplete-plugin-algolia-insights@1.19.4": - version "1.19.4" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.4.tgz#be14ba50677ea308d43e4f9e96f4542c3da51432" - integrity sha512-K6TQhTKxx0Es1ZbjlAQjgm/QLDOtKvw23MX0xmpvO7AwkmlmaEXo2PwHdVSs3Bquv28CkO2BYKks7jVSIdcXUg== +"@algolia/autocomplete-plugin-algolia-insights@1.19.5": + version "1.19.5" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.5.tgz#05246356fe9837475b08664ff4d6f55960127edc" + integrity sha512-5zbetV9h2VxH+Mxx27I7BH2EIACVRUBE1FNykBK+2c2M+mhXYMY4npHbbGYj6QDEw3VVvH2UxAnghFpCtC6B/w== dependencies: - "@algolia/autocomplete-shared" "1.19.4" + "@algolia/autocomplete-shared" "1.19.5" "@algolia/autocomplete-plugin-recent-searches@^1.17.0": - version "1.19.4" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-recent-searches/-/autocomplete-plugin-recent-searches-1.19.4.tgz#f3a013438f915aac8258481a6504a18bad432c8f" - integrity sha512-8LLAedqcvztFweNWFQuqz9lWIiVlPi+wLF+3qWLPWQZQY3E4bVsbnxVfL9z4AMX9G0lljd2dQitn+Vwkl96d7Q== + version "1.19.5" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-recent-searches/-/autocomplete-plugin-recent-searches-1.19.5.tgz#afd80f8abb281c4c01817a1edfde9a8aa95ed5db" + integrity sha512-lOEliMbohq0BsZJ7JXFHlfmGBNtuCsQW0PLq8m6X1SdMD4XAn8fFxiOO2Nk1A/IiymZcOoHQV71u6f14wiohDw== dependencies: - "@algolia/autocomplete-core" "1.19.4" - "@algolia/autocomplete-js" "1.19.4" - "@algolia/autocomplete-preset-algolia" "1.19.4" - "@algolia/autocomplete-shared" "1.19.4" + "@algolia/autocomplete-core" "1.19.5" + "@algolia/autocomplete-js" "1.19.5" + "@algolia/autocomplete-preset-algolia" "1.19.5" + "@algolia/autocomplete-shared" "1.19.5" -"@algolia/autocomplete-preset-algolia@1.19.4": - version "1.19.4" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.19.4.tgz#258c65112d73376c5c395d1ce67cd668deb06572" - integrity sha512-WhX4mYosy7yBDjkB6c/ag+WKICjvV2fqQv/+NWJlpvnk2JtMaZByi73F6svpQX945J+/PxpQe8YIRBZHuYsLAQ== +"@algolia/autocomplete-preset-algolia@1.19.5": + version "1.19.5" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.19.5.tgz#a9d5756090314c16b8895fa0c74ffccca7f8a1e2" + integrity sha512-afdgxUyBxgX1I34THLScCyC+ld2h8wnCTv7JndRxsRNIJjJpFtRNpnYDq0+HVcp+LYeNd1zksDu7CpltTSEsvA== dependencies: - "@algolia/autocomplete-shared" "1.19.4" + "@algolia/autocomplete-shared" "1.19.5" -"@algolia/autocomplete-shared@1.19.4": - version "1.19.4" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.4.tgz#fd0b92e2723e70c97df4fa7ba0a170c500289918" - integrity sha512-V7tYDgRXP0AqL4alwZBWNm1HPWjJvEU94Nr7Qa2cuPcIAbsTAj7M/F/+Pv/iwOWXl3N7tzVzNkOWm7sX6JT1SQ== +"@algolia/autocomplete-shared@1.19.5": + version "1.19.5" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.5.tgz#1a20f60fd400fd5641718358a2d5c3eb1893cf9c" + integrity sha512-yblBczNXtm2cCVzX4UAY3KkjdefmZPn1gWbIi8Q7qfBw7FjcKq2EjEl/65x4kU9nUc/ZkB5SeUf/bkqLEnA5gA== "@algolia/autocomplete-theme-classic@^1.17.0": - version "1.19.4" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-theme-classic/-/autocomplete-theme-classic-1.19.4.tgz#7a0802e7c64dcc3584d5085e23a290a64ade4319" - integrity sha512-/qE8BETNFbul4WrrUyBYgaaKcgFPk0Px9FDKADnr3HlIkXquRpcFHTxXK16jdwXb33yrcXaAVSQZRfUUSSnxVA== + version "1.19.5" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-theme-classic/-/autocomplete-theme-classic-1.19.5.tgz#7b0d3ac11f2dca33600fce9ac383056ab4202cdc" + integrity sha512-LjjhOmDbEXmV2IqaA7Xe8jh6lSpG087yC79ffLpXMKJOib4xSHFvPavsXC8NW25pWVHJFoAfplAAmxmeM2/jhw== "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": version "7.29.0" @@ -1859,10 +1859,10 @@ resolved "https://registry.yarnpkg.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz#3081dadbc3460661b751e7591d7faea5df39dd29" integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ== -"@isaacs/brace-expansion@^5.0.0": - version "5.0.0" - resolved "https://registry.yarnpkg.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz#4b3dabab7d8e75a429414a96bd67bf4c1d13e0f3" - integrity sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA== +"@isaacs/brace-expansion@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz#0ef5a92d91f2fff2a37646ce54da9e5f599f6eff" + integrity sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ== dependencies: "@isaacs/balanced-match" "^4.0.1" @@ -2169,9 +2169,9 @@ integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== "@types/node@*": - version "25.1.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-25.1.0.tgz#95cc584f1f478301efc86de4f1867e5875e83571" - integrity sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA== + version "25.2.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.2.0.tgz#015b7d228470c1dcbfc17fe9c63039d216b4d782" + integrity sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w== dependencies: undici-types "~7.16.0" @@ -2790,9 +2790,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001759: - version "1.0.30001766" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz#b6f6b55cb25a2d888d9393104d14751c6a7d6f7a" - integrity sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA== + version "1.0.30001767" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz#0279c498e862efb067938bba0a0aabafe8d0b730" + integrity sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ== ccount@^2.0.0: version "2.0.1" @@ -3671,9 +3671,9 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1: gopd "^1.2.0" electron-to-chromium@^1.5.263: - version "1.5.283" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz#51d492c37c2d845a0dccb113fe594880c8616de8" - integrity sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w== + version "1.5.286" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz#142be1ab5e1cd5044954db0e5898f60a4960384e" + integrity sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A== emoji-regex@^7.0.1: version "7.0.3" @@ -3690,13 +3690,13 @@ emojis-list@^3.0.0: resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== -enhanced-resolve@^5.0.0, enhanced-resolve@^5.17.4: - version "5.18.4" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz#c22d33055f3952035ce6a144ce092447c525f828" - integrity sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q== +enhanced-resolve@^5.0.0, enhanced-resolve@^5.19.0: + version "5.19.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz#6687446a15e969eaa63c2fa2694510e17ae6d97c" + integrity sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg== dependencies: graceful-fs "^4.2.4" - tapable "^2.2.0" + tapable "^2.3.0" entities@^2.0.0: version "2.2.0" @@ -5589,11 +5589,11 @@ mini-css-extract-plugin@^2.4.2, mini-css-extract-plugin@^2.6.0: tapable "^2.2.1" minimatch@*: - version "10.1.1" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.1.1.tgz#e6e61b9b0c1dcab116b5a7d1458e8b6ae9e73a55" - integrity sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ== + version "10.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.1.2.tgz#6c3f289f9de66d628fa3feb1842804396a43d81c" + integrity sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw== dependencies: - "@isaacs/brace-expansion" "^5.0.0" + "@isaacs/brace-expansion" "^5.0.1" minimatch@3.0.4: version "3.0.4" @@ -7371,7 +7371,7 @@ tagged-tag@^1.0.0: resolved "https://registry.yarnpkg.com/tagged-tag/-/tagged-tag-1.0.0.tgz#a0b5917c2864cba54841495abfa3f6b13edcf4d6" integrity sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng== -tapable@^2.0.0, tapable@^2.2.0, tapable@^2.2.1, tapable@^2.3.0: +tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6" integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg== @@ -7455,9 +7455,9 @@ to-regex-range@^5.0.1: is-number "^7.0.0" tom-select@^2.1.0: - version "2.4.3" - resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.3.tgz#1daa4131cd317de691f39eb5bf41148265986c1f" - integrity sha512-MFFrMxP1bpnAMPbdvPCZk0KwYxLqhYZso39torcdoefeV/NThNyDu8dV96/INJ5XQVTL3O55+GqQ78Pkj5oCfw== + version "2.4.5" + resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.5.tgz#5c91355c9bf024ff5bc9389f8a2a370e4a28126a" + integrity sha512-ujZ5P10kRohKDFElklhkO4dRM+WkUEaytHhOuzbQkZ6MyiR8e2IwGKXab4v+ZNipE2queN8ztlb0MmRLqoM6QA== dependencies: "@orchidjs/sifter" "^1.1.0" "@orchidjs/unicode-variants" "^1.1.2" @@ -7747,7 +7747,7 @@ vfile@^6.0.0: "@types/unist" "^3.0.0" vfile-message "^4.0.0" -watchpack@^2.4.4: +watchpack@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.5.1.tgz#dd38b601f669e0cbf567cb802e75cead82cde102" integrity sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg== @@ -7843,9 +7843,9 @@ webpack-sources@^3.3.3: integrity sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg== webpack@^5.74.0: - version "5.104.1" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.104.1.tgz#94bd41eb5dbf06e93be165ba8be41b8260d4fb1a" - integrity sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA== + version "5.105.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.105.0.tgz#38b5e6c5db8cbe81debbd16e089335ada05ea23a" + integrity sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw== dependencies: "@types/eslint-scope" "^3.7.7" "@types/estree" "^1.0.8" @@ -7857,7 +7857,7 @@ webpack@^5.74.0: acorn-import-phases "^1.0.3" browserslist "^4.28.1" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.17.4" + enhanced-resolve "^5.19.0" es-module-lexer "^2.0.0" eslint-scope "5.1.1" events "^3.2.0" @@ -7870,7 +7870,7 @@ webpack@^5.74.0: schema-utils "^4.3.3" tapable "^2.3.0" terser-webpack-plugin "^5.3.16" - watchpack "^2.4.4" + watchpack "^2.5.1" webpack-sources "^3.3.3" which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: From ea748dc469e0c90cb95444de3c7e306b19c7fb0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 3 Feb 2026 21:49:31 +0100 Subject: [PATCH 75/78] Use cache.app adapter for settings content cache --- config/packages/cache.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index c1816aa2..846033d6 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -25,5 +25,5 @@ framework: adapter: cache.app cache.settings: - adapter: cache.system + adapter: cache.app tags: true From b48de83a3289d6df55a90b4baaa12fb9603612d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 3 Feb 2026 23:04:18 +0100 Subject: [PATCH 76/78] Use brick schema to implement GenericWebProvider This is less error prone than our own parser and also allows to parse Microdata and rdfa lite to support more webshops --- composer.json | 1 + composer.lock | 173 +++++++++++++++++- .../Providers/GenericWebProvider.php | 170 ++++++++--------- 3 files changed, 260 insertions(+), 84 deletions(-) diff --git a/composer.json b/composer.json index 8ce686c2..36dd461e 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "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 56ab8701..28d7c981 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": "8e387d6d016f33eb7302c47ecb7a12b9", + "content-hash": "7ca9c95fb85f6bf3d9b8a3aa98ca33f6", "packages": [ { "name": "amphp/amp", @@ -2387,6 +2387,117 @@ ], "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", @@ -9595,6 +9706,66 @@ }, "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", diff --git a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php index 66d45707..e85ce5f4 100644 --- a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php +++ b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php @@ -32,6 +32,18 @@ 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\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 Brick\StructuredData\HTMLReader; +use Brick\StructuredData\Reader\JsonLdReader; +use Brick\StructuredData\Reader\MicrodataReader; +use Brick\StructuredData\Reader\RdfaLiteReader; +use Brick\StructuredData\Reader\ReaderChain; use Symfony\Component\DomCrawler\Crawler; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -104,126 +116,122 @@ class GenericWebProvider implements InfoProviderInterface return $host; } - private function productJsonLdToPart(array $jsonLd, string $url, Crawler $dom): PartDetailDTO + private function productToPart(Product $product, string $url, Crawler $dom): PartDetailDTO { - $notes = $jsonLd['description'] ?? ""; - if (isset($jsonLd['disambiguatingDescription'])) { + $notes = $product->description->toString() ?? ""; + if ($product->disambiguatingDescription !== null) { if (!empty($notes)) { $notes .= "\n\n"; } - $notes .= $jsonLd['disambiguatingDescription']; + $notes .= $product->disambiguatingDescription->toString(); } + + //Extract vendor infos $vendor_infos = 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']; - } - + $offer = $product->offers->getFirstValue(); + if ($offer !== null) { $prices = []; - if (isset($offer['price'])) { - $prices[] = new PriceDTO( + if ($offer->price->toString() !== null) { + $prices = [new PriceDTO( minimum_discount_amount: 1, - 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( + 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( minimum_discount_amount: 1, - price: (string) $subOffer['price'], - currency_iso_code: $subOffer['priceCurrency'] ?? null - ); - } + price: $offer2->price->toString(), + currency_iso_code: $offer2->priceCurrency?->toString() + ) + ]; } } $vendor_infos = [new PurchaseInfoDTO( distributor_name: $this->extractShopName($url), - order_number: (string) ($jsonLd['sku'] ?? $jsonLd['@id'] ?? $jsonLd['gtin'] ?? 'Unknown'), + order_number: $product->sku?->toString() ?? $product->identifier?->toString() ?? 'Unknown', prices: $prices, - product_url: $jsonLd['url'] ?? $url, + product_url: $offer->url?->toString() ?? $url, )]; } + //Extract image: $image = null; - 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 ($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 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; - } - //Try to extract parameters from additionalProperty + //Extract parameters from additionalProperty $parameters = []; - if (isset($jsonLd['additionalProperty']) && array_is_list($jsonLd['additionalProperty'])) { - foreach ($jsonLd['additionalProperty'] as $property) { //TODO: Handle minValue and maxValue - if (isset ($property['unitText'])) { + foreach ($product->additionalProperty->getValues() as $property) { + if ($property instanceof PropertyValue) { //TODO: Handle minValue and maxValue + if ($property->unitText->toString() !== null) { $parameters[] = ParameterDTO::parseValueField( - name: $property['name'] ?? 'Unknown', - value: $property['value'] ?? '', - unit: $property['unitText'] + name: $property->name->toString() ?? 'Unknown', + value: $property->value->toString() ?? '', + unit: $property->unitText->toString() ); } else { $parameters[] = ParameterDTO::parseValueIncludingUnit( - name: $property['name'] ?? 'Unknown', - value: $property['value'] ?? '' + name: $property->name->toString() ?? 'Unknown', + value: $property->value->toString() ?? '' ); } } } + //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: $jsonLd ['name'] ?? 'Unknown Name', + name: $product->name?->toString() ?? $product->alternateName?->toString() ?? $product?->mpn->toString() ?? 'Unknown Name', description: $this->getMetaContent($dom, 'og:description') ?? $this->getMetaContent($dom, 'description') ?? '', - category: isset($jsonLd['category']) && is_string($jsonLd['category']) ? $jsonLd['category'] : null, - manufacturer: $jsonLd['manufacturer']['name'] ?? $jsonLd['brand']['name'] ?? null, - mpn: $jsonLd['mpn'] ?? null, + category: $product->category?->toString(), + manufacturer: self::propertyOrString($product->manufacturer) ?? self::propertyOrString($product->brand), + mpn: $product->mpn?->toString(), preview_image_url: $image, provider_url: $url, notes: $notes, parameters: $parameters, vendor_infos: $vendor_infos, - mass: isset($jsonLd['weight']['value']) ? (float)$jsonLd['weight']['value'] : null, + mass: $mass ); } - /** - * 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 + private static function propertyOrString(SchemaTypeList|Thing|string|null $value, string $property = "name"): ?string { - //Sanitize common issues - $json = preg_replace("/[\r\n]+/", " ", $json); - return json_decode($json, true, 512, JSON_THROW_ON_ERROR); + 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; } + /** * Gets the content of a meta tag by its name or property attribute, or null if not found * @param Crawler $dom @@ -336,18 +344,14 @@ class GenericWebProvider implements InfoProviderInterface $canonicalURL = $scheme.'://'.$host.$canonicalURL; } - //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]; - } - foreach ($jsonLd as $item) { - if (isset($item['@type']) && $item['@type'] === 'Product') { - return $this->productJsonLdToPart($item, $canonicalURL, $dom); - } + + $schemaReader = SchemaReader::forAllFormats(); + $things = $schemaReader->readHtml($content, $canonicalURL); + + //Try to find a Product schema + foreach ($things as $thing) { + if ($thing instanceof Product) { + return $this->productToPart($thing, $canonicalURL, $dom); } } From 7d19ed3ca8754df68927ac96b078c159e245d590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 3 Feb 2026 23:20:13 +0100 Subject: [PATCH 77/78] Try to get a category from a webshop based on the breadcrumbs --- .../Providers/GenericWebProvider.php | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php index e85ce5f4..6d27beb2 100644 --- a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php +++ b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php @@ -32,6 +32,7 @@ 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; @@ -39,11 +40,6 @@ use Brick\Schema\Interfaces\QuantitativeValue; use Brick\Schema\Interfaces\Thing; use Brick\Schema\SchemaReader; use Brick\Schema\SchemaTypeList; -use Brick\StructuredData\HTMLReader; -use Brick\StructuredData\Reader\JsonLdReader; -use Brick\StructuredData\Reader\MicrodataReader; -use Brick\StructuredData\Reader\RdfaLiteReader; -use Brick\StructuredData\Reader\ReaderChain; use Symfony\Component\DomCrawler\Crawler; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -116,7 +112,33 @@ class GenericWebProvider implements InfoProviderInterface return $host; } - private function productToPart(Product $product, string $url, Crawler $dom): PartDetailDTO + private function breadcrumbToCategory(?BreadcrumbList $breadcrumbList): ?string + { + 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) { @@ -200,7 +222,7 @@ class GenericWebProvider implements InfoProviderInterface provider_id: $url, name: $product->name?->toString() ?? $product->alternateName?->toString() ?? $product?->mpn->toString() ?? 'Unknown Name', description: $this->getMetaContent($dom, 'og:description') ?? $this->getMetaContent($dom, 'description') ?? '', - category: $product->category?->toString(), + category: $this->breadcrumbToCategory($categoryBreadcrumb) ?? $product->category?->toString(), manufacturer: self::propertyOrString($product->manufacturer) ?? self::propertyOrString($product->brand), mpn: $product->mpn?->toString(), preview_image_url: $image, @@ -348,10 +370,19 @@ class GenericWebProvider implements InfoProviderInterface $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 a Product schema foreach ($things as $thing) { if ($thing instanceof Product) { - return $this->productToPart($thing, $canonicalURL, $dom); + return $this->productToPart($thing, $canonicalURL, $dom, $categoryBreadCrumbs); } } From 061af28c485523596caf14eb9bb35c3afa96e037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 7 Feb 2026 17:07:53 +0100 Subject: [PATCH 78/78] Fixed phpstan issues in GenericWebProvider --- phpstan.dist.neon | 3 +++ .../InfoProviderSystem/Providers/GenericWebProvider.php | 9 +++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/phpstan.dist.neon b/phpstan.dist.neon index eb629314..b03c20c2 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -67,3 +67,6 @@ parameters: - message: '#Should not use function "shell_exec"#' path: src/Services/System/UpdateExecutor.php + + - message: '#Access to an undefined property Brick\\Schema\\Interfaces\\#' + path: src/Services/InfoProviderSystem/Providers/GenericWebProvider.php diff --git a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php index 6d27beb2..7fbf5a58 100644 --- a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php +++ b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php @@ -213,14 +213,14 @@ class GenericWebProvider implements InfoProviderInterface //Try to extract weight $mass = null; - if (($weight = $product?->weight->getFirstValue()) instanceof QuantitativeValue) { + 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: $product->name?->toString() ?? $product->alternateName?->toString() ?? $product->mpn?->toString() ?? '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), @@ -247,10 +247,7 @@ class GenericWebProvider implements InfoProviderInterface return $value; } - if ($value instanceof Thing) { - return $value->$property?->toString(); - } - return null; + return $value->$property?->toString(); }