Merge branch 'master' into generic_webshop

This commit is contained in:
Jan Böhmer 2026-02-03 21:51:27 +01:00
commit 518953ad45
45 changed files with 5552 additions and 344 deletions

11
.env
View file

@ -59,6 +59,17 @@ ERROR_PAGE_ADMIN_EMAIL=''
# If this is set to true, solutions to common problems are shown on error pages. Disable this, if you do not want your users to see them...
ERROR_PAGE_SHOW_HELP=1
###################################################################################
# Update Manager settings
###################################################################################
# Disable web-based updates from the Update Manager UI (0=enabled, 1=disabled).
# When disabled, use the CLI command "php bin/console partdb:update" instead.
DISABLE_WEB_UPDATES=1
# Disable backup restore from the Update Manager UI (0=enabled, 1=disabled).
# Restoring backups is a destructive operation that could overwrite your database.
DISABLE_BACKUP_RESTORE=1
###################################################################################
# SAML Single sign on-settings

View file

@ -1 +1 @@
2.5.1
2.6.0-dev

View file

@ -0,0 +1,55 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Controller } from '@hotwired/stimulus';
/**
* Stimulus controller for backup restore confirmation dialogs.
* Shows a confirmation dialog with backup details before allowing restore.
*/
export default class extends Controller {
static values = {
filename: { type: String, default: '' },
date: { type: String, default: '' },
confirmTitle: { type: String, default: 'Restore Backup' },
confirmMessage: { type: String, default: 'Are you sure you want to restore from this backup?' },
confirmWarning: { type: String, default: 'This will overwrite your current database. This action cannot be undone!' },
};
connect() {
this.element.addEventListener('submit', this.handleSubmit.bind(this));
}
handleSubmit(event) {
// Always prevent default first
event.preventDefault();
// Build confirmation message
const message = this.confirmTitleValue + '\n\n' +
'Backup: ' + this.filenameValue + '\n' +
'Date: ' + this.dateValue + '\n\n' +
this.confirmMessageValue + '\n\n' +
'⚠️ ' + this.confirmWarningValue;
// Only submit if user confirms
if (confirm(message)) {
this.element.submit();
}
}
}

View file

@ -0,0 +1,81 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Controller } from '@hotwired/stimulus';
/**
* Stimulus controller for update/downgrade confirmation dialogs.
* Intercepts form submission and shows a confirmation dialog before proceeding.
*/
export default class extends Controller {
static values = {
isDowngrade: { type: Boolean, default: false },
targetVersion: { type: String, default: '' },
confirmUpdate: { type: String, default: 'Are you sure you want to update Part-DB?' },
confirmDowngrade: { type: String, default: 'Are you sure you want to downgrade Part-DB?' },
downgradeWarning: { type: String, default: 'WARNING: This version does not include the Update Manager.' },
minUpdateManagerVersion: { type: String, default: '2.6.0' },
};
connect() {
this.element.addEventListener('submit', this.handleSubmit.bind(this));
}
handleSubmit(event) {
// Always prevent default first
event.preventDefault();
const targetClean = this.targetVersionValue.replace(/^v/, '');
let message;
if (this.isDowngradeValue) {
// Check if downgrading to a version without Update Manager
if (this.compareVersions(targetClean, this.minUpdateManagerVersionValue) < 0) {
message = this.confirmDowngradeValue + '\n\n⚠ ' + this.downgradeWarningValue;
} else {
message = this.confirmDowngradeValue;
}
} else {
message = this.confirmUpdateValue;
}
// Only submit if user confirms
if (confirm(message)) {
// Remove the event listener to prevent infinite loop, then submit
this.element.removeEventListener('submit', this.handleSubmit.bind(this));
this.element.submit();
}
}
/**
* Compare two version strings (e.g., "2.5.0" vs "2.6.0")
* Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2
*/
compareVersions(v1, v2) {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const p1 = parts1[i] || 0;
const p2 = parts2[i] || 0;
if (p1 < p2) return -1;
if (p1 > p2) return 1;
}
return 0;
}
}

View file

@ -11,6 +11,7 @@
"ext-intl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-zip": "*",
"amphp/http-client": "^5.1",
"api-platform/doctrine-orm": "^4.1",
"api-platform/json-api": "^4.0.0",

80
composer.lock generated
View file

@ -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": {

View file

@ -23,3 +23,7 @@ framework:
info_provider.cache:
adapter: cache.app
cache.settings:
adapter: cache.app
tags: true

View file

@ -3,6 +3,7 @@ jbtronics_settings:
cache:
default_cacheable: true
service: 'cache.settings'
orm_storage:
default_entity_class: App\Entity\SettingsEntry

View file

@ -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:

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View file

@ -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

View file

@ -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

View file

@ -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!"

View file

@ -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

View file

@ -0,0 +1,141 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Command;
use App\Services\System\UpdateExecutor;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand('partdb:maintenance-mode', 'Enable/disable maintenance mode and set a message')]
class MaintenanceModeCommand extends Command
{
public function __construct(
private readonly UpdateExecutor $updateExecutor
) {
parent::__construct();
}
protected function configure(): void
{
$this
->setDefinition([
new InputOption('enable', null, InputOption::VALUE_NONE, 'Enable maintenance mode'),
new InputOption('disable', null, InputOption::VALUE_NONE, 'Disable maintenance mode'),
new InputOption('status', null, InputOption::VALUE_NONE, 'Show current maintenance mode status'),
new InputOption('message', null, InputOption::VALUE_REQUIRED, 'Optional maintenance message (explicit option)'),
new InputArgument('message_arg', InputArgument::OPTIONAL, 'Optional maintenance message as a positional argument (preferred when writing message directly)')
]);
}
public function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$enable = (bool)$input->getOption('enable');
$disable = (bool)$input->getOption('disable');
$status = (bool)$input->getOption('status');
// Accept message either via --message option or as positional argument
$optionMessage = $input->getOption('message');
$argumentMessage = $input->getArgument('message_arg');
// Prefer explicit --message option, otherwise use positional argument if provided
$message = null;
if (is_string($optionMessage) && $optionMessage !== '') {
$message = $optionMessage;
} elseif (is_string($argumentMessage) && $argumentMessage !== '') {
$message = $argumentMessage;
}
// If no action provided, show help
if (!$enable && !$disable && !$status) {
$io->text('Maintenance mode command. See usage below:');
$this->printHelp($io);
return Command::SUCCESS;
}
if ($enable && $disable) {
$io->error('Conflicting options: specify either --enable or --disable, not both.');
return Command::FAILURE;
}
try {
if ($status) {
if ($this->updateExecutor->isMaintenanceMode()) {
$info = $this->updateExecutor->getMaintenanceInfo();
$reason = $info['reason'] ?? 'Unknown reason';
$enabledAt = $info['enabled_at'] ?? 'Unknown time';
$io->success(sprintf('Maintenance mode is ENABLED (since %s).', $enabledAt));
$io->text(sprintf('Reason: %s', $reason));
} else {
$io->success('Maintenance mode is DISABLED.');
}
// If only status requested, exit
if (!$enable && !$disable) {
return Command::SUCCESS;
}
}
if ($enable) {
// Use provided message or fallback to a default English message
$reason = is_string($message)
? $message
: 'The system is temporarily unavailable due to maintenance.';
$this->updateExecutor->enableMaintenanceMode($reason);
$io->success(sprintf('Maintenance mode enabled. Reason: %s', $reason));
}
if ($disable) {
$this->updateExecutor->disableMaintenanceMode();
$io->success('Maintenance mode disabled.');
}
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error(sprintf('Unexpected error: %s', $e->getMessage()));
return Command::FAILURE;
}
}
private function printHelp(SymfonyStyle $io): void
{
$io->writeln('');
$io->writeln('Usage:');
$io->writeln(' php bin/console partdb:maintenance_mode --enable [--message="Maintenance message"]');
$io->writeln(' php bin/console partdb:maintenance_mode --enable "Maintenance message"');
$io->writeln(' php bin/console partdb:maintenance_mode --disable');
$io->writeln(' php bin/console partdb:maintenance_mode --status');
$io->writeln('');
}
}

View file

@ -0,0 +1,445 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Command;
use App\Services\System\UpdateChecker;
use App\Services\System\UpdateExecutor;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'partdb:update', description: 'Check for and install Part-DB updates', aliases: ['app:update'])]
class UpdateCommand extends Command
{
public function __construct(private readonly UpdateChecker $updateChecker,
private readonly UpdateExecutor $updateExecutor)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setHelp(<<<'HELP'
The <info>%command.name%</info> command checks for Part-DB updates and can install them.
<comment>Check for updates:</comment>
<info>php %command.full_name% --check</info>
<comment>List available versions:</comment>
<info>php %command.full_name% --list</info>
<comment>Update to the latest version:</comment>
<info>php %command.full_name%</info>
<comment>Update to a specific version:</comment>
<info>php %command.full_name% v2.6.0</info>
<comment>Update without creating a backup (faster but riskier):</comment>
<info>php %command.full_name% --no-backup</info>
<comment>Non-interactive update for scripts:</comment>
<info>php %command.full_name% --force</info>
<comment>View update logs:</comment>
<info>php %command.full_name% --logs</info>
HELP
)
->addArgument(
'version',
InputArgument::OPTIONAL,
'Target version to update to (e.g., v2.6.0). If not specified, updates to the latest stable version.'
)
->addOption(
'check',
'c',
InputOption::VALUE_NONE,
'Only check for updates without installing'
)
->addOption(
'list',
'l',
InputOption::VALUE_NONE,
'List all available versions'
)
->addOption(
'no-backup',
null,
InputOption::VALUE_NONE,
'Skip creating a backup before updating (not recommended)'
)
->addOption(
'force',
'f',
InputOption::VALUE_NONE,
'Skip confirmation prompts'
)
->addOption(
'include-prerelease',
null,
InputOption::VALUE_NONE,
'Include pre-release versions'
)
->addOption(
'logs',
null,
InputOption::VALUE_NONE,
'Show recent update logs'
)
->addOption(
'refresh',
'r',
InputOption::VALUE_NONE,
'Force refresh of cached version information'
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// Handle --logs option
if ($input->getOption('logs')) {
return $this->showLogs($io);
}
// Handle --refresh option
if ($input->getOption('refresh')) {
$io->text('Refreshing version information...');
$this->updateChecker->refreshVersionInfo();
$io->success('Version cache cleared.');
}
// Handle --list option
if ($input->getOption('list')) {
return $this->listVersions($io, $input->getOption('include-prerelease'));
}
// Get update status
$status = $this->updateChecker->getUpdateStatus();
// Display current status
$io->title('Part-DB Update Manager');
$this->displayStatus($io, $status);
// Handle --check option
if ($input->getOption('check')) {
return $this->checkOnly($io, $status);
}
// Validate we can update
$validationResult = $this->validateUpdate($io, $status);
if ($validationResult !== null) {
return $validationResult;
}
// Determine target version
$targetVersion = $input->getArgument('version');
$includePrerelease = $input->getOption('include-prerelease');
if (!$targetVersion) {
$latest = $this->updateChecker->getLatestVersion($includePrerelease);
if (!$latest) {
$io->error('Could not determine the latest version. Please specify a version manually.');
return Command::FAILURE;
}
$targetVersion = $latest['tag'];
}
// Validate target version
if (!$this->updateChecker->isNewerVersionThanCurrent($targetVersion)) {
$io->warning(sprintf(
'Version %s is not newer than the current version %s.',
$targetVersion,
$status['current_version']
));
if (!$input->getOption('force')) {
if (!$io->confirm('Do you want to proceed anyway?', false)) {
$io->info('Update cancelled.');
return Command::SUCCESS;
}
}
}
// Confirm update
if (!$input->getOption('force')) {
$io->section('Update Plan');
$io->listing([
sprintf('Target version: <info>%s</info>', $targetVersion),
$input->getOption('no-backup')
? '<fg=yellow>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('<info>%s</info>', $status['current_version'])],
['Latest Version' => $status['latest_version']
? sprintf('<info>%s</info>', $status['latest_version'])
: '<fg=yellow>Unknown</>'],
['Installation Type' => $status['installation']['type_name']],
['Git Branch' => $status['git']['branch'] ?? '<fg=gray>N/A</>'],
['Git Commit' => $status['git']['commit'] ?? '<fg=gray>N/A</>'],
['Local Changes' => $status['git']['has_local_changes']
? '<fg=yellow>Yes (update blocked)</>'
: '<fg=green>No</>'],
['Commits Behind' => $status['git']['commits_behind'] > 0
? sprintf('<fg=yellow>%d</>', $status['git']['commits_behind'])
: '<fg=green>0</>'],
['Update Available' => $status['update_available']
? '<fg=green>Yes</>'
: 'No'],
['Can Auto-Update' => $status['can_auto_update']
? '<fg=green>Yes</>'
: '<fg=yellow>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: <href=%s>%s</>', $status['release_url'], $status['release_url']));
}
if ($status['can_auto_update']) {
$io->text('');
$io->text('Run <info>php bin/console partdb:update</info> 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: <info>%s</info>', $targetVersion));
$io->text('');
$progressCallback = function (array $step) use ($io): void {
$icon = $step['success'] ? '<fg=green>✓</>' : '<fg=red>✗</>';
$duration = $step['duration'] ? sprintf(' <fg=gray>(%.1fs)</>', $step['duration']) : '';
$io->text(sprintf(' %s <info>%s</info>: %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: <info>%s</info>', $result['rollback_tag']),
sprintf('Log file: <info>%s</info>', $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[] = '<fg=cyan>current</>';
} elseif (version_compare($version, $currentVersion, '>')) {
$status[] = '<fg=green>newer</>';
}
if ($release['prerelease']) {
$status[] = '<fg=yellow>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 <info>php bin/console partdb:update [tag]</info> 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: <info>var/log/updates/</info>');
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]);
}
}

View file

@ -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);

View file

@ -24,9 +24,9 @@ namespace App\Controller;
use App\DataTables\LogDataTable;
use App\Entity\Parts\Part;
use App\Services\Misc\GitVersionInfo;
use App\Services\System\BannerHelper;
use App\Services\System\UpdateAvailableManager;
use App\Services\System\GitVersionInfoProvider;
use App\Services\System\UpdateAvailableFacade;
use Doctrine\ORM\EntityManagerInterface;
use Omines\DataTablesBundle\DataTableFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -43,8 +43,8 @@ class HomepageController extends AbstractController
#[Route(path: '/', name: 'homepage')]
public function homepage(Request $request, GitVersionInfo $versionInfo, EntityManagerInterface $entityManager,
UpdateAvailableManager $updateAvailableManager): Response
public function homepage(Request $request, GitVersionInfoProvider $versionInfo, EntityManagerInterface $entityManager,
UpdateAvailableFacade $updateAvailableManager): Response
{
$this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS');
@ -77,8 +77,8 @@ class HomepageController extends AbstractController
return $this->render('homepage.html.twig', [
'banner' => $this->bannerHelper->getBanner(),
'git_branch' => $versionInfo->getGitBranchName(),
'git_commit' => $versionInfo->getGitCommitHash(),
'git_branch' => $versionInfo->getBranchName(),
'git_commit' => $versionInfo->getCommitHash(),
'show_first_steps' => $show_first_steps,
'datatable' => $table,
'new_version_available' => $updateAvailableManager->isUpdateAvailable(),

View file

@ -27,8 +27,8 @@ use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Attachments\BuiltinAttachmentsFinder;
use App\Services\Doctrine\DBInfoHelper;
use App\Services\Doctrine\NatsortDebugHelper;
use App\Services\Misc\GitVersionInfo;
use App\Services\System\UpdateAvailableManager;
use App\Services\System\GitVersionInfoProvider;
use App\Services\System\UpdateAvailableFacade;
use App\Settings\AppSettings;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
@ -47,16 +47,16 @@ class ToolsController extends AbstractController
}
#[Route(path: '/server_infos', name: 'tools_server_infos')]
public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper,
AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager,
public function systemInfos(GitVersionInfoProvider $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper,
AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableFacade $updateAvailableManager,
AppSettings $settings): Response
{
$this->denyAccessUnlessGranted('@system.server_infos');
return $this->render('tools/server_infos/server_infos.html.twig', [
//Part-DB section
'git_branch' => $versionInfo->getGitBranchName(),
'git_commit' => $versionInfo->getGitCommitHash(),
'git_branch' => $versionInfo->getBranchName(),
'git_commit' => $versionInfo->getCommitHash(),
'default_locale' => $settings->system->localization->locale,
'default_timezone' => $settings->system->localization->timezone,
'default_currency' => $settings->system->localization->baseCurrency,

View file

@ -0,0 +1,371 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Controller;
use App\Services\System\BackupManager;
use App\Services\System\UpdateChecker;
use App\Services\System\UpdateExecutor;
use Shivas\VersioningBundle\Service\VersionManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Attribute\Route;
/**
* Controller for the Update Manager web interface.
*
* This provides a read-only view of update status and instructions.
* Actual updates should be performed via the CLI command for safety.
*/
#[Route('/system/update-manager')]
class UpdateManagerController extends AbstractController
{
public function __construct(
private readonly UpdateChecker $updateChecker,
private readonly UpdateExecutor $updateExecutor,
private readonly VersionManagerInterface $versionManager,
private readonly BackupManager $backupManager,
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
private readonly bool $webUpdatesDisabled = false,
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
private readonly bool $backupRestoreDisabled = false,
) {
}
/**
* Check if web updates are disabled and throw exception if so.
*/
private function denyIfWebUpdatesDisabled(): void
{
if ($this->webUpdatesDisabled) {
throw new AccessDeniedHttpException('Web-based updates are disabled by server configuration. Please use the CLI command instead.');
}
}
/**
* Check if backup restore is disabled and throw exception if so.
*/
private function denyIfBackupRestoreDisabled(): void
{
if ($this->backupRestoreDisabled) {
throw new AccessDeniedHttpException('Backup restore is disabled by server configuration.');
}
}
/**
* Main update manager page.
*/
#[Route('', name: 'admin_update_manager', methods: ['GET'])]
public function index(): Response
{
$this->denyAccessUnlessGranted('@system.show_updates');
$status = $this->updateChecker->getUpdateStatus();
$availableUpdates = $this->updateChecker->getAvailableUpdates();
$validation = $this->updateExecutor->validateUpdatePreconditions();
return $this->render('admin/update_manager/index.html.twig', [
'status' => $status,
'available_updates' => $availableUpdates,
'all_releases' => $this->updateChecker->getAvailableReleases(10),
'validation' => $validation,
'is_locked' => $this->updateExecutor->isLocked(),
'lock_info' => $this->updateExecutor->getLockInfo(),
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
'maintenance_info' => $this->updateExecutor->getMaintenanceInfo(),
'update_logs' => $this->updateExecutor->getUpdateLogs(),
'backups' => $this->backupManager->getBackups(),
'web_updates_disabled' => $this->webUpdatesDisabled,
'backup_restore_disabled' => $this->backupRestoreDisabled,
]);
}
/**
* AJAX endpoint to check update status.
*/
#[Route('/status', name: 'admin_update_manager_status', methods: ['GET'])]
public function status(): JsonResponse
{
$this->denyAccessUnlessGranted('@system.show_updates');
return $this->json([
'status' => $this->updateChecker->getUpdateStatus(),
'is_locked' => $this->updateExecutor->isLocked(),
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
'lock_info' => $this->updateExecutor->getLockInfo(),
]);
}
/**
* AJAX endpoint to refresh version information.
*/
#[Route('/refresh', name: 'admin_update_manager_refresh', methods: ['POST'])]
public function refresh(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('@system.show_updates');
// Validate CSRF token
if (!$this->isCsrfTokenValid('update_manager_refresh', $request->request->get('_token'))) {
return $this->json(['error' => 'Invalid CSRF token'], Response::HTTP_FORBIDDEN);
}
$this->updateChecker->refreshVersionInfo();
return $this->json([
'success' => true,
'status' => $this->updateChecker->getUpdateStatus(),
]);
}
/**
* View release notes for a specific version.
*/
#[Route('/release/{tag}', name: 'admin_update_manager_release', methods: ['GET'])]
public function releaseNotes(string $tag): Response
{
$this->denyAccessUnlessGranted('@system.show_updates');
$releases = $this->updateChecker->getAvailableReleases(20);
$release = null;
foreach ($releases as $r) {
if ($r['tag'] === $tag) {
$release = $r;
break;
}
}
if (!$release) {
throw $this->createNotFoundException('Release not found');
}
return $this->render('admin/update_manager/release_notes.html.twig', [
'release' => $release,
'current_version' => $this->updateChecker->getCurrentVersionString(),
]);
}
/**
* View an update log file.
*/
#[Route('/log/{filename}', name: 'admin_update_manager_log', methods: ['GET'])]
public function viewLog(string $filename): Response
{
$this->denyAccessUnlessGranted('@system.manage_updates');
// Security: Only allow viewing files from the update logs directory
$logs = $this->updateExecutor->getUpdateLogs();
$logPath = null;
foreach ($logs as $log) {
if ($log['file'] === $filename) {
$logPath = $log['path'];
break;
}
}
if (!$logPath || !file_exists($logPath)) {
throw $this->createNotFoundException('Log file not found');
}
$content = file_get_contents($logPath);
return $this->render('admin/update_manager/log_viewer.html.twig', [
'filename' => $filename,
'content' => $content,
]);
}
/**
* Start an update process.
*/
#[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])]
public function startUpdate(Request $request): Response
{
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfWebUpdatesDisabled();
// Validate CSRF token
if (!$this->isCsrfTokenValid('update_manager_start', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token');
return $this->redirectToRoute('admin_update_manager');
}
// Check if update is already running
if ($this->updateExecutor->isLocked() || $this->updateExecutor->isUpdateRunning()) {
$this->addFlash('error', 'An update is already in progress.');
return $this->redirectToRoute('admin_update_manager');
}
$targetVersion = $request->request->get('version');
$createBackup = $request->request->getBoolean('backup', true);
if (!$targetVersion) {
// Get latest version if not specified
$latest = $this->updateChecker->getLatestVersion();
if (!$latest) {
$this->addFlash('error', 'Could not determine target version.');
return $this->redirectToRoute('admin_update_manager');
}
$targetVersion = $latest['tag'];
}
// Validate preconditions
$validation = $this->updateExecutor->validateUpdatePreconditions();
if (!$validation['valid']) {
$this->addFlash('error', implode(' ', $validation['errors']));
return $this->redirectToRoute('admin_update_manager');
}
// Start the background update
$pid = $this->updateExecutor->startBackgroundUpdate($targetVersion, $createBackup);
if (!$pid) {
$this->addFlash('error', 'Failed to start update process.');
return $this->redirectToRoute('admin_update_manager');
}
// Redirect to progress page
return $this->redirectToRoute('admin_update_manager_progress');
}
/**
* Update progress page.
*/
#[Route('/progress', name: 'admin_update_manager_progress', methods: ['GET'])]
public function progress(): Response
{
$this->denyAccessUnlessGranted('@system.manage_updates');
$progress = $this->updateExecutor->getProgress();
$currentVersion = $this->versionManager->getVersion()->toString();
// Determine if this is a downgrade
$isDowngrade = false;
if ($progress && isset($progress['target_version'])) {
$targetVersion = ltrim($progress['target_version'], 'v');
$isDowngrade = version_compare($targetVersion, $currentVersion, '<');
}
return $this->render('admin/update_manager/progress.html.twig', [
'progress' => $progress,
'is_locked' => $this->updateExecutor->isLocked(),
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
'is_downgrade' => $isDowngrade,
'current_version' => $currentVersion,
]);
}
/**
* AJAX endpoint to get update progress.
*/
#[Route('/progress/status', name: 'admin_update_manager_progress_status', methods: ['GET'])]
public function progressStatus(): JsonResponse
{
$this->denyAccessUnlessGranted('@system.show_updates');
$progress = $this->updateExecutor->getProgress();
return $this->json([
'progress' => $progress,
'is_locked' => $this->updateExecutor->isLocked(),
'is_maintenance' => $this->updateExecutor->isMaintenanceMode(),
]);
}
/**
* Get backup details for restore confirmation.
*/
#[Route('/backup/{filename}', name: 'admin_update_manager_backup_details', methods: ['GET'])]
public function backupDetails(string $filename): JsonResponse
{
$this->denyAccessUnlessGranted('@system.manage_updates');
$details = $this->backupManager->getBackupDetails($filename);
if (!$details) {
return $this->json(['error' => 'Backup not found'], 404);
}
return $this->json($details);
}
/**
* Restore from a backup.
*/
#[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
public function restore(Request $request): Response
{
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfBackupRestoreDisabled();
// Validate CSRF token
if (!$this->isCsrfTokenValid('update_manager_restore', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('admin_update_manager');
}
// Check if already locked
if ($this->updateExecutor->isLocked()) {
$this->addFlash('error', 'An update or restore is already in progress.');
return $this->redirectToRoute('admin_update_manager');
}
$filename = $request->request->get('filename');
$restoreDatabase = $request->request->getBoolean('restore_database', true);
$restoreConfig = $request->request->getBoolean('restore_config', false);
$restoreAttachments = $request->request->getBoolean('restore_attachments', false);
if (!$filename) {
$this->addFlash('error', 'No backup file specified.');
return $this->redirectToRoute('admin_update_manager');
}
// Verify the backup exists
$backupDetails = $this->backupManager->getBackupDetails($filename);
if (!$backupDetails) {
$this->addFlash('error', 'Backup file not found.');
return $this->redirectToRoute('admin_update_manager');
}
// Execute restore (this is a synchronous operation for now - could be made async later)
$result = $this->updateExecutor->restoreBackup(
$filename,
$restoreDatabase,
$restoreConfig,
$restoreAttachments
);
if ($result['success']) {
$this->addFlash('success', 'Backup restored successfully.');
} else {
$this->addFlash('error', 'Restore failed: ' . ($result['error'] ?? 'Unknown error'));
}
return $this->redirectToRoute('admin_update_manager');
}
}

View file

@ -0,0 +1,230 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Services\System\UpdateExecutor;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Twig\Environment;
/**
* Blocks all web requests when maintenance mode is enabled during updates.
*/
readonly class MaintenanceModeSubscriber implements EventSubscriberInterface
{
public function __construct(private UpdateExecutor $updateExecutor)
{
}
public static function getSubscribedEvents(): array
{
return [
// High priority to run before other listeners
KernelEvents::REQUEST => ['onKernelRequest', 512], //High priority to run before other listeners
];
}
public function onKernelRequest(RequestEvent $event): void
{
// Only handle main requests
if (!$event->isMainRequest()) {
return;
}
// Skip if not in maintenance mode
if (!$this->updateExecutor->isMaintenanceMode()) {
return;
}
//Allow to view the progress page
if (preg_match('#^/\w{2}/system/update-manager/progress#', $event->getRequest()->getPathInfo())) {
return;
}
// Allow CLI requests
if (PHP_SAPI === 'cli') {
return;
}
// Get maintenance info
$maintenanceInfo = $this->updateExecutor->getMaintenanceInfo();
// Calculate how long the update has been running
$duration = null;
if ($maintenanceInfo && isset($maintenanceInfo['enabled_at'])) {
try {
$startedAt = new \DateTime($maintenanceInfo['enabled_at']);
$now = new \DateTime();
$duration = $now->getTimestamp() - $startedAt->getTimestamp();
} catch (\Exception) {
// Ignore date parsing errors
}
}
$content = $this->getSimpleMaintenanceHtml($maintenanceInfo, $duration);
$response = new Response($content, Response::HTTP_SERVICE_UNAVAILABLE);
$response->headers->set('Retry-After', '30');
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate');
$event->setResponse($response);
}
/**
* Generate a simple maintenance page HTML without Twig.
*/
private function getSimpleMaintenanceHtml(?array $maintenanceInfo, ?int $duration): string
{
$reason = htmlspecialchars($maintenanceInfo['reason'] ?? 'Update in progress');
$durationText = $duration !== null ? sprintf('%d seconds', $duration) : 'a moment';
$startDateStr = $maintenanceInfo['enabled_at'] ?? 'unknown time';
return <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="15">
<title>Part-DB - Maintenance</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
color: #ffffff;
}
.container {
text-align: center;
padding: 40px;
max-width: 600px;
}
.icon {
font-size: 80px;
margin-bottom: 30px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
display: inline-block;
animation: spin 2s linear infinite;
}
h1 {
font-size: 2.5rem;
margin-bottom: 20px;
color: #00d4ff;
}
p {
font-size: 1.2rem;
margin-bottom: 15px;
color: #b8c5d6;
}
.reason {
background: rgba(255, 255, 255, 0.1);
padding: 15px 25px;
border-radius: 10px;
margin: 20px 0;
font-size: 1rem;
}
.progress-bar {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
overflow: hidden;
margin: 30px 0;
}
.progress-bar-inner {
height: 100%;
background: linear-gradient(90deg, #00d4ff, #00ff88);
border-radius: 3px;
animation: progress 3s ease-in-out infinite;
}
@keyframes progress {
0% { width: 0%; margin-left: 0%; }
50% { width: 50%; margin-left: 25%; }
100% { width: 0%; margin-left: 100%; }
}
.info {
font-size: 0.9rem;
color: #8899aa;
margin-top: 30px;
}
.duration {
font-family: monospace;
background: rgba(0, 212, 255, 0.2);
padding: 3px 8px;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">
<span class="spinner">⚙️</span>
</div>
<h1>Part-DB is under maintenance</h1>
<p>We're making things better. This should only take a moment.</p>
<div class="reason">
<strong>{$reason}</strong>
</div>
<div class="progress-bar">
<div class="progress-bar-inner"></div>
</div>
<p class="info">
Maintenance mode active since <span class="duration">{$startDateStr}</span><br>
<br>
Started <span class="duration">{$durationText}</span> ago<br>
<small>This page will automatically refresh every 15 seconds.</small>
</p>
</div>
</body>
</html>
HTML;
}
}

View file

@ -1,83 +0,0 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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
}
}

View file

@ -0,0 +1,453 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\System;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shivas\VersioningBundle\Service\VersionManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Process;
/**
* Manages Part-DB backups: creation, restoration, and listing.
*
* This service handles all backup-related operations and can be used
* by the Update Manager, CLI commands, or other services.
*/
readonly class BackupManager
{
private const BACKUP_DIR = 'var/backups';
public function __construct(
#[Autowire(param: 'kernel.project_dir')]
private string $projectDir,
private LoggerInterface $logger,
private Filesystem $filesystem,
private VersionManagerInterface $versionManager,
private EntityManagerInterface $entityManager,
private CommandRunHelper $commandRunHelper,
) {
}
/**
* Get the backup directory path.
*/
public function getBackupDir(): string
{
return $this->projectDir . '/' . self::BACKUP_DIR;
}
/**
* Get the current version string for use in filenames.
*/
private function getCurrentVersionString(): string
{
return $this->versionManager->getVersion()->toString();
}
/**
* Create a backup before updating.
*
* @param string|null $targetVersion Optional target version for naming
* @param string|null $prefix Optional prefix for the backup filename
* @return string The path to the created backup file
*/
public function createBackup(?string $targetVersion = null, ?string $prefix = 'backup'): string
{
$backupDir = $this->getBackupDir();
if (!is_dir($backupDir)) {
$this->filesystem->mkdir($backupDir, 0755);
}
$currentVersion = $this->getCurrentVersionString();
// Build filename
if ($targetVersion) {
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
$backupFile = $backupDir . '/pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.zip';
} else {
$backupFile = $backupDir . '/' . $prefix . '-v' . $currentVersion . '-' . date('Y-m-d-His') . '.zip';
}
$this->commandRunHelper->runCommand([
'php', 'bin/console', 'partdb:backup',
'--full',
'--overwrite',
$backupFile,
], 'Create backup', 600);
$this->logger->info('Created backup', ['file' => $backupFile]);
return $backupFile;
}
/**
* Get list of backups, that are available, sorted by date descending.
*
* @return array<array{file: string, path: string, date: int, size: int}>
*/
public function getBackups(): array
{
$backupDir = $this->getBackupDir();
if (!is_dir($backupDir)) {
return [];
}
$backups = [];
foreach (glob($backupDir . '/*.zip') as $backupFile) {
$backups[] = [
'file' => basename($backupFile),
'path' => $backupFile,
'date' => filemtime($backupFile),
'size' => filesize($backupFile),
];
}
// Sort by date descending
usort($backups, static fn($a, $b) => $b['date'] <=> $a['date']);
return $backups;
}
/**
* Get details about a specific backup file.
*
* @param string $filename The backup filename
* @return null|array{file: string, path: string, date: int, size: int, from_version: ?string, to_version: ?string, contains_database?: bool, contains_config?: bool, contains_attachments?: bool} Backup details or null if not found
*/
public function getBackupDetails(string $filename): ?array
{
$backupDir = $this->getBackupDir();
$backupPath = $backupDir . '/' . basename($filename);
if (!file_exists($backupPath) || !str_ends_with($backupPath, '.zip')) {
return null;
}
// Parse version info from filename: pre-update-v2.5.1-to-v2.5.0-2024-01-30-185400.zip
$info = [
'file' => basename($backupPath),
'path' => $backupPath,
'date' => filemtime($backupPath),
'size' => filesize($backupPath),
'from_version' => null,
'to_version' => null,
];
if (preg_match('/pre-update-v([\d.]+)-to-v?([\d.]+)-/', $filename, $matches)) {
$info['from_version'] = $matches[1];
$info['to_version'] = $matches[2];
}
// Check what the backup contains by reading the ZIP
try {
$zip = new \ZipArchive();
if ($zip->open($backupPath) === true) {
$info['contains_database'] = $zip->locateName('database.sql') !== false || $zip->locateName('var/app.db') !== false;
$info['contains_config'] = $zip->locateName('.env.local') !== false || $zip->locateName('config/parameters.yaml') !== false;
$info['contains_attachments'] = $zip->locateName('public/media/') !== false || $zip->locateName('uploads/') !== false;
$zip->close();
}
} catch (\Exception $e) {
$this->logger->warning('Could not read backup ZIP contents', ['error' => $e->getMessage()]);
}
return $info;
}
/**
* Delete a backup file.
*
* @param string $filename The backup filename to delete
* @return bool True if deleted successfully
*/
public function deleteBackup(string $filename): bool
{
$backupDir = $this->getBackupDir();
$backupPath = $backupDir . '/' . basename($filename);
if (!file_exists($backupPath) || !str_ends_with($backupPath, '.zip')) {
return false;
}
try {
$this->filesystem->remove($backupPath);
$this->logger->info('Deleted backup', ['file' => $filename]);
return true;
} catch (\Exception $e) {
$this->logger->error('Failed to delete backup', ['file' => $filename, 'error' => $e->getMessage()]);
return false;
}
}
/**
* Restore from a backup file.
*
* @param string $filename The backup filename to restore
* @param bool $restoreDatabase Whether to restore the database
* @param bool $restoreConfig Whether to restore config files
* @param bool $restoreAttachments Whether to restore attachments
* @param callable|null $onProgress Callback for progress updates
* @return array{success: bool, steps: array, error: ?string}
*/
public function restoreBackup(
string $filename,
bool $restoreDatabase = true,
bool $restoreConfig = false,
bool $restoreAttachments = false,
?callable $onProgress = null
): array {
$steps = [];
$startTime = microtime(true);
$log = function (string $step, string $message, bool $success, ?float $duration = null) use (&$steps, $onProgress): void {
$entry = [
'step' => $step,
'message' => $message,
'success' => $success,
'timestamp' => (new \DateTime())->format('c'),
'duration' => $duration,
];
$steps[] = $entry;
$this->logger->info('[Restore] ' . $step . ': ' . $message, ['success' => $success]);
if ($onProgress) {
$onProgress($entry);
}
};
try {
// Validate backup file
$backupDir = $this->getBackupDir();
$backupPath = $backupDir . '/' . basename($filename);
if (!file_exists($backupPath)) {
throw new \RuntimeException('Backup file not found: ' . $filename);
}
$stepStart = microtime(true);
// Step 1: Extract backup to temp directory
$tempDir = sys_get_temp_dir() . '/partdb_restore_' . uniqid();
$this->filesystem->mkdir($tempDir);
$zip = new \ZipArchive();
if ($zip->open($backupPath) !== true) {
throw new \RuntimeException('Could not open backup ZIP file');
}
$zip->extractTo($tempDir);
$zip->close();
$log('extract', 'Extracted backup to temporary directory', true, microtime(true) - $stepStart);
// Step 2: Restore database if requested and present
if ($restoreDatabase) {
$stepStart = microtime(true);
$this->restoreDatabaseFromBackup($tempDir);
$log('database', 'Restored database', true, microtime(true) - $stepStart);
}
// Step 3: Restore config files if requested and present
if ($restoreConfig) {
$stepStart = microtime(true);
$this->restoreConfigFromBackup($tempDir);
$log('config', 'Restored configuration files', true, microtime(true) - $stepStart);
}
// Step 4: Restore attachments if requested and present
if ($restoreAttachments) {
$stepStart = microtime(true);
$this->restoreAttachmentsFromBackup($tempDir);
$log('attachments', 'Restored attachments', true, microtime(true) - $stepStart);
}
// Step 5: Clean up temp directory
$stepStart = microtime(true);
$this->filesystem->remove($tempDir);
$log('cleanup', 'Cleaned up temporary files', true, microtime(true) - $stepStart);
$totalDuration = microtime(true) - $startTime;
$log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true);
return [
'success' => true,
'steps' => $steps,
'error' => null,
];
} catch (\Throwable $e) {
$this->logger->error('Restore failed: ' . $e->getMessage(), [
'exception' => $e,
'file' => $filename,
]);
// Try to clean up
try {
if (isset($tempDir) && is_dir($tempDir)) {
$this->filesystem->remove($tempDir);
}
} catch (\Throwable $cleanupError) {
$this->logger->error('Cleanup after failed restore also failed', ['error' => $cleanupError->getMessage()]);
}
return [
'success' => false,
'steps' => $steps,
'error' => $e->getMessage(),
];
}
}
/**
* Restore database from backup.
*/
private function restoreDatabaseFromBackup(string $tempDir): void
{
// Check for SQL dump (MySQL/PostgreSQL)
$sqlFile = $tempDir . '/database.sql';
if (file_exists($sqlFile)) {
// Import SQL using mysql/psql command directly
// First, get database connection params from Doctrine
$connection = $this->entityManager->getConnection();
$params = $connection->getParams();
$platform = $connection->getDatabasePlatform();
if ($platform instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
// Use mysql command to import - need to use shell to handle input redirection
$mysqlCmd = 'mysql';
if (isset($params['host'])) {
$mysqlCmd .= ' -h ' . escapeshellarg($params['host']);
}
if (isset($params['port'])) {
$mysqlCmd .= ' -P ' . escapeshellarg((string)$params['port']);
}
if (isset($params['user'])) {
$mysqlCmd .= ' -u ' . escapeshellarg($params['user']);
}
if (isset($params['password']) && $params['password']) {
$mysqlCmd .= ' -p' . escapeshellarg($params['password']);
}
if (isset($params['dbname'])) {
$mysqlCmd .= ' ' . escapeshellarg($params['dbname']);
}
$mysqlCmd .= ' < ' . escapeshellarg($sqlFile);
// Execute using shell
$process = Process::fromShellCommandline($mysqlCmd, $this->projectDir, null, null, 300);
$process->run();
if (!$process->isSuccessful()) {
throw new \RuntimeException('MySQL import failed: ' . $process->getErrorOutput());
}
} elseif ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
// Use psql command to import
$psqlCmd = 'psql';
if (isset($params['host'])) {
$psqlCmd .= ' -h ' . escapeshellarg($params['host']);
}
if (isset($params['port'])) {
$psqlCmd .= ' -p ' . escapeshellarg((string)$params['port']);
}
if (isset($params['user'])) {
$psqlCmd .= ' -U ' . escapeshellarg($params['user']);
}
if (isset($params['dbname'])) {
$psqlCmd .= ' -d ' . escapeshellarg($params['dbname']);
}
$psqlCmd .= ' -f ' . escapeshellarg($sqlFile);
// Set PGPASSWORD environment variable if password is provided
$env = null;
if (isset($params['password']) && $params['password']) {
$env = ['PGPASSWORD' => $params['password']];
}
// Execute using shell
$process = Process::fromShellCommandline($psqlCmd, $this->projectDir, $env, null, 300);
$process->run();
if (!$process->isSuccessful()) {
throw new \RuntimeException('PostgreSQL import failed: ' . $process->getErrorOutput());
}
} else {
throw new \RuntimeException('Unsupported database platform for restore');
}
return;
}
// Check for SQLite database file
$sqliteFile = $tempDir . '/var/app.db';
if (file_exists($sqliteFile)) {
$targetDb = $this->projectDir . '/var/app.db';
$this->filesystem->copy($sqliteFile, $targetDb, true);
return;
}
$this->logger->warning('No database found in backup');
}
/**
* Restore config files from backup.
*/
private function restoreConfigFromBackup(string $tempDir): void
{
// Restore .env.local
$envLocal = $tempDir . '/.env.local';
if (file_exists($envLocal)) {
$this->filesystem->copy($envLocal, $this->projectDir . '/.env.local', true);
}
// Restore config/parameters.yaml
$parametersYaml = $tempDir . '/config/parameters.yaml';
if (file_exists($parametersYaml)) {
$this->filesystem->copy($parametersYaml, $this->projectDir . '/config/parameters.yaml', true);
}
// Restore config/banner.md
$bannerMd = $tempDir . '/config/banner.md';
if (file_exists($bannerMd)) {
$this->filesystem->copy($bannerMd, $this->projectDir . '/config/banner.md', true);
}
}
/**
* Restore attachments from backup.
*/
private function restoreAttachmentsFromBackup(string $tempDir): void
{
// Restore public/media
$publicMedia = $tempDir . '/public/media';
if (is_dir($publicMedia)) {
$this->filesystem->mirror($publicMedia, $this->projectDir . '/public/media', null, ['override' => true]);
}
// Restore uploads
$uploads = $tempDir . '/uploads';
if (is_dir($uploads)) {
$this->filesystem->mirror($uploads, $this->projectDir . '/uploads', null, ['override' => true]);
}
}
}

View file

@ -0,0 +1,73 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\System;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Process\Process;
class CommandRunHelper
{
public function __construct(
#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir
)
{
}
/**
* Run a shell command with proper error handling.
*/
public function runCommand(array $command, string $description, int $timeout = 120): string
{
$process = new Process($command, $this->project_dir);
$process->setTimeout($timeout);
// Set environment variables needed for Composer and other tools
// This is especially important when running as www-data which may not have HOME set
// We inherit from current environment and override/add specific variables
$currentEnv = getenv();
if (!is_array($currentEnv)) {
$currentEnv = [];
}
$env = array_merge($currentEnv, [
'HOME' => $this->project_dir.'/var/www-data-home',
'COMPOSER_HOME' => $this->project_dir.'/var/composer',
'PATH' => getenv('PATH') ?: '/usr/local/bin:/usr/bin:/bin',
]);
$process->setEnv($env);
$output = '';
$process->run(function ($type, $buffer) use (&$output) {
$output .= $buffer;
});
if (!$process->isSuccessful()) {
$errorOutput = $process->getErrorOutput() ?: $process->getOutput();
throw new \RuntimeException(
sprintf('%s failed: %s', $description, trim($errorOutput))
);
}
return $output;
}
}

View file

@ -0,0 +1,141 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\System;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Process\Process;
/**
* This service provides information about the current Git installation (if any).
*/
final readonly class GitVersionInfoProvider
{
public function __construct(#[Autowire(param: 'kernel.project_dir')] private string $project_dir)
{
}
/**
* Check if the project directory is a Git repository.
* @return bool
*/
public function isGitRepo(): bool
{
return is_dir($this->getGitDirectory());
}
/**
* Get the path to the Git directory of the installed system without a trailing slash.
* Even if this is no Git installation, the path is returned.
* @return string The path to the Git directory of the installed system
*/
public function getGitDirectory(): string
{
return $this->project_dir . '/.git';
}
/**
* Get the Git branch name of the installed system.
*
* @return string|null The current git branch name. Null, if this is no Git installation
*/
public function getBranchName(): ?string
{
if (is_file($this->getGitDirectory() . '/HEAD')) {
$git = file($this->getGitDirectory() . '/HEAD');
$head = explode('/', $git[0], 3);
if (!isset($head[2])) {
return null;
}
return trim($head[2]);
}
return null; // this is not a Git installation
}
/**
* Get hash of the last git commit (on remote "origin"!).
*
* If this method does not work, try to make a "git pull" first!
*
* @param int $length if this is smaller than 40, only the first $length characters will be returned
*
* @return string|null The hash of the last commit, null If this is no Git installation
*/
public function getCommitHash(int $length = 8): ?string
{
$path = $this->getGitDirectory() . '/HEAD';
if (!file_exists($path)) {
return null;
}
$head = trim(file_get_contents($path));
// If it's a symbolic ref (e.g., "ref: refs/heads/main")
if (str_starts_with($head, 'ref:')) {
$refPath = $this->getGitDirectory() . '/' . trim(substr($head, 5));
if (file_exists($refPath)) {
$hash = trim(file_get_contents($refPath));
}
} else {
// Otherwise, it's a detached HEAD (the hash is right there)
$hash = $head;
}
return isset($hash) ? substr($hash, 0, $length) : null;
}
/**
* Get the Git remote URL of the installed system.
*/
public function getRemoteURL(): ?string
{
// Get remote URL
$configFile = $this->getGitDirectory() . '/config';
if (file_exists($configFile)) {
$config = file_get_contents($configFile);
if (preg_match('#url = (.+)#', $config, $matches)) {
return trim($matches[1]);
}
}
return null; // this is not a Git installation
}
/**
* Check if there are local changes in the Git repository.
* Attention: This runs a git command, which might be slow!
* @return bool|null True if there are local changes, false if not, null if this is not a Git installation
*/
public function hasLocalChanges(): ?bool
{
$process = new Process(['git', 'status', '--porcelain'], $this->project_dir);
$process->run();
if (!$process->isSuccessful()) {
return null; // this is not a Git installation
}
return !empty(trim($process->getOutput()));
}
}

View file

@ -0,0 +1,65 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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.',
};
}
}

View file

@ -0,0 +1,153 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\System;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Process\Process;
readonly class InstallationTypeDetector
{
public function __construct(#[Autowire(param: 'kernel.project_dir')] private string $project_dir, private GitVersionInfoProvider $gitVersionInfoProvider)
{
}
/**
* Detect the installation type based on filesystem markers.
*/
public function detect(): InstallationType
{
// Check for Docker environment first
if ($this->isDocker()) {
return InstallationType::DOCKER;
}
// Check for Git installation
if ($this->isGitInstall()) {
return InstallationType::GIT;
}
// Check for ZIP release (has VERSION file but no .git)
if ($this->isZipRelease()) {
return InstallationType::ZIP_RELEASE;
}
return InstallationType::UNKNOWN;
}
/**
* Check if running inside a Docker container.
*/
public function isDocker(): bool
{
// Check for /.dockerenv file
if (file_exists('/.dockerenv')) {
return true;
}
// Check for DOCKER environment variable
if (getenv('DOCKER') !== false) {
return true;
}
// Check for container runtime in cgroup
if (file_exists('/proc/1/cgroup')) {
$cgroup = @file_get_contents('/proc/1/cgroup');
if ($cgroup !== false && (str_contains($cgroup, 'docker') || str_contains($cgroup, 'containerd'))) {
return true;
}
}
return false;
}
/**
* Check if this is a Git-based installation.
*/
public function isGitInstall(): bool
{
return $this->gitVersionInfoProvider->isGitRepo();
}
/**
* Check if this appears to be a ZIP release installation.
*/
public function isZipRelease(): bool
{
// Has VERSION file but no .git directory
return file_exists($this->project_dir . '/VERSION') && !$this->isGitInstall();
}
/**
* Get detailed information about the installation.
*/
public function getInstallationInfo(): array
{
$type = $this->detect();
$info = [
'type' => $type,
'type_name' => $type->getLabel(),
'supports_auto_update' => $type->supportsAutoUpdate(),
'update_instructions' => $type->getUpdateInstructions(),
'project_dir' => $this->project_dir,
];
if ($type === InstallationType::GIT) {
$info['git'] = $this->getGitInfo();
}
if ($type === InstallationType::DOCKER) {
$info['docker'] = $this->getDockerInfo();
}
return $info;
}
/**
* Get Git-specific information.
* @return array{branch: string|null, commit: string|null, remote_url: string|null, has_local_changes: bool}
*/
private function getGitInfo(): array
{
return [
'branch' => $this->gitVersionInfoProvider->getBranchName(),
'commit' => $this->gitVersionInfoProvider->getCommitHash(8),
'remote_url' => $this->gitVersionInfoProvider->getRemoteURL(),
'has_local_changes' => $this->gitVersionInfoProvider->hasLocalChanges() ?? false,
];
}
/**
* Get Docker-specific information.
* @return array{container_id: string|null, image: string|null}
*/
private function getDockerInfo(): array
{
return [
'container_id' => @file_get_contents('/proc/1/cpuset') ?: null,
'image' => getenv('DOCKER_IMAGE') ?: null,
];
}
}

View file

@ -24,28 +24,23 @@ declare(strict_types=1);
namespace App\Services\System;
use App\Settings\SystemSettings\PrivacySettings;
use Psr\Log\LoggerInterface;
use Shivas\VersioningBundle\Service\VersionManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Version\Version;
/**
* This class checks if a new version of Part-DB is available.
*/
class UpdateAvailableManager
class UpdateAvailableFacade
{
private const API_URL = 'https://api.github.com/repos/Part-DB/Part-DB-server/releases/latest';
private const CACHE_KEY = 'uam_latest_version';
private const CACHE_TTL = 60 * 60 * 24 * 2; // 2 day
public function __construct(private readonly HttpClientInterface $httpClient,
private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager,
private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger,
#[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode)
public function __construct(
private readonly CacheInterface $updateCache,
private readonly PrivacySettings $privacySettings,
private readonly UpdateChecker $updateChecker,
)
{
}
@ -89,9 +84,7 @@ class UpdateAvailableManager
}
$latestVersion = $this->getLatestVersion();
$currentVersion = $this->versionManager->getVersion();
return $latestVersion->isGreaterThan($currentVersion);
return $this->updateChecker->isNewerVersionThanCurrent($latestVersion);
}
/**
@ -111,34 +104,7 @@ class UpdateAvailableManager
return $this->updateCache->get(self::CACHE_KEY, function (ItemInterface $item) {
$item->expiresAfter(self::CACHE_TTL);
try {
$response = $this->httpClient->request('GET', self::API_URL);
$result = $response->toArray();
$tag_name = $result['tag_name'];
// Remove the leading 'v' from the tag name
$version = substr($tag_name, 1);
return [
'version' => $version,
'url' => $result['html_url'],
];
} catch (\Exception $e) {
//When we are in dev mode, throw the exception, otherwise just silently log it
if ($this->is_dev_mode) {
throw $e;
}
//In the case of an error, try it again after half of the cache time
$item->expiresAfter(self::CACHE_TTL / 2);
$this->logger->error('Checking for updates failed: ' . $e->getMessage());
return [
'version' => '0.0.1',
'url' => 'update-checking-error'
];
}
return $this->updateChecker->getLatestVersion();
});
}
}
}

View file

@ -0,0 +1,338 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\System;
use App\Settings\SystemSettings\PrivacySettings;
use Psr\Log\LoggerInterface;
use Shivas\VersioningBundle\Service\VersionManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Process\Process;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Version\Version;
/**
* Enhanced update checker that fetches release information including changelogs.
*/
class UpdateChecker
{
private const GITHUB_API_BASE = 'https://api.github.com/repos/Part-DB/Part-DB-server';
private const CACHE_KEY_RELEASES = 'update_checker_releases';
private const CACHE_KEY_COMMITS = 'update_checker_commits_behind';
private const CACHE_TTL = 60 * 60 * 6; // 6 hours
private const CACHE_TTL_ERROR = 60 * 60; // 1 hour on error
public function __construct(private readonly HttpClientInterface $httpClient,
private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager,
private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger,
private readonly InstallationTypeDetector $installationTypeDetector,
private readonly GitVersionInfoProvider $gitVersionInfoProvider,
#[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode,
#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir)
{
}
/**
* Get the current installed version.
*/
public function getCurrentVersion(): Version
{
return $this->versionManager->getVersion();
}
/**
* Get the current version as string.
*/
public function getCurrentVersionString(): string
{
return $this->getCurrentVersion()->toString();
}
/**
* Get Git repository information.
* @return array{branch: ?string, commit: ?string, has_local_changes: bool, commits_behind: int, is_git_install: bool}
*/
private function getGitInfo(): array
{
$info = [
'branch' => null,
'commit' => null,
'has_local_changes' => false,
'commits_behind' => 0,
'is_git_install' => false,
];
if (!$this->gitVersionInfoProvider->isGitRepo()) {
return $info;
}
$info['is_git_install'] = true;
$info['branch'] = $this->gitVersionInfoProvider->getBranchName();
$info['commit'] = $this->gitVersionInfoProvider->getCommitHash(8);
$info['has_local_changes'] = $this->gitVersionInfoProvider->hasLocalChanges();
// Get commits behind (fetch first)
if ($info['branch']) {
// Try to get cached commits behind count
$info['commits_behind'] = $this->getCommitsBehind($info['branch']);
}
return $info;
}
/**
* Get number of commits behind the remote branch (cached).
*/
private function getCommitsBehind(string $branch): int
{
if (!$this->privacySettings->checkForUpdates) {
return 0;
}
$cacheKey = self::CACHE_KEY_COMMITS . '_' . hash('xxh3', $branch);
return $this->updateCache->get($cacheKey, function (ItemInterface $item) use ($branch) {
$item->expiresAfter(self::CACHE_TTL);
// Fetch from remote first
$process = new Process(['git', 'fetch', '--tags', 'origin'], $this->project_dir);
$process->run();
// Count commits behind
$process = new Process(['git', 'rev-list', 'HEAD..origin/' . $branch, '--count'], $this->project_dir);
$process->run();
return $process->isSuccessful() ? (int) trim($process->getOutput()) : 0;
});
}
/**
* Force refresh git information by invalidating cache.
*/
public function refreshVersionInfo(): void
{
$gitBranch = $this->gitVersionInfoProvider->getBranchName();
if ($gitBranch) {
$this->updateCache->delete(self::CACHE_KEY_COMMITS . '_' . hash('xxh3', $gitBranch));
}
$this->updateCache->delete(self::CACHE_KEY_RELEASES);
}
/**
* Get all available releases from GitHub (cached).
*
* @return array<array{version: string, tag: string, name: string, url: string, published_at: string, body: string, prerelease: bool, draft:bool, assets: array, tarball_url: ?string, zipball_url: ?string}>
*/
public function getAvailableReleases(int $limit = 10): array
{
if (!$this->privacySettings->checkForUpdates) {
return [];
}
return $this->updateCache->get(self::CACHE_KEY_RELEASES, function (ItemInterface $item) use ($limit) {
$item->expiresAfter(self::CACHE_TTL);
try {
$response = $this->httpClient->request('GET', self::GITHUB_API_BASE . '/releases', [
'query' => ['per_page' => $limit],
'headers' => [
'Accept' => 'application/vnd.github.v3+json',
'User-Agent' => 'Part-DB-Update-Checker',
],
]);
$releases = [];
foreach ($response->toArray() as $release) {
// Extract assets (for ZIP download)
$assets = [];
foreach ($release['assets'] ?? [] as $asset) {
if (str_ends_with($asset['name'], '.zip') || str_ends_with($asset['name'], '.tar.gz')) {
$assets[] = [
'name' => $asset['name'],
'url' => $asset['browser_download_url'],
'size' => $asset['size'],
];
}
}
$releases[] = [
'version' => ltrim($release['tag_name'], 'v'),
'tag' => $release['tag_name'],
'name' => $release['name'] ?? $release['tag_name'],
'url' => $release['html_url'],
'published_at' => $release['published_at'],
'body' => $release['body'] ?? '',
'prerelease' => $release['prerelease'] ?? false,
'draft' => $release['draft'] ?? false,
'assets' => $assets,
'tarball_url' => $release['tarball_url'] ?? null,
'zipball_url' => $release['zipball_url'] ?? null,
];
}
return $releases;
} catch (\Exception $e) {
$this->logger->error('Failed to fetch releases from GitHub: ' . $e->getMessage());
$item->expiresAfter(self::CACHE_TTL_ERROR);
if ($this->is_dev_mode) {
throw $e;
}
return [];
}
});
}
/**
* Get the latest stable release.
* @return array{version: string, tag: string, name: string, url: string, published_at: string, body: string, prerelease: bool, assets: array}|null
*/
public function getLatestVersion(bool $includePrerelease = false): ?array
{
$releases = $this->getAvailableReleases();
foreach ($releases as $release) {
// Skip drafts always
if ($release['draft']) {
continue;
}
// Skip prereleases unless explicitly included
if (!$includePrerelease && $release['prerelease']) {
continue;
}
return $release;
}
return null;
}
/**
* Check if a specific version is newer than current.
*/
public function isNewerVersionThanCurrent(Version|string $version): bool
{
if ($version instanceof Version) {
return $version->isGreaterThan($this->getCurrentVersion());
}
try {
return Version::fromString(ltrim($version, 'v'))->isGreaterThan($this->getCurrentVersion());
} catch (\Exception) {
return false;
}
}
/**
* Get comprehensive update status.
* @return array{current_version: string, latest_version: ?string, latest_tag: ?string, update_available: bool, release_notes: ?string, release_url: ?string,
* published_at: ?string, git: array, installation: array, can_auto_update: bool, update_blockers: array, check_enabled: bool}
*/
public function getUpdateStatus(): array
{
$current = $this->getCurrentVersion();
$latest = $this->getLatestVersion();
$gitInfo = $this->getGitInfo();
$installInfo = $this->installationTypeDetector->getInstallationInfo();
$updateAvailable = false;
$latestVersion = null;
$latestTag = null;
if ($latest) {
try {
$latestVersionObj = Version::fromString($latest['version']);
$updateAvailable = $latestVersionObj->isGreaterThan($current);
$latestVersion = $latest['version'];
$latestTag = $latest['tag'];
} catch (\Exception) {
// Invalid version string
}
}
// Determine if we can auto-update
$canAutoUpdate = $installInfo['supports_auto_update'];
$updateBlockers = [];
if ($gitInfo['has_local_changes']) {
$canAutoUpdate = false;
$updateBlockers[] = 'local_changes';
}
if ($installInfo['type'] === InstallationType::DOCKER) {
$updateBlockers[] = 'docker_installation';
}
return [
'current_version' => $current->toString(),
'latest_version' => $latestVersion,
'latest_tag' => $latestTag,
'update_available' => $updateAvailable,
'release_notes' => $latest['body'] ?? null,
'release_url' => $latest['url'] ?? null,
'published_at' => $latest['published_at'] ?? null,
'git' => $gitInfo,
'installation' => $installInfo,
'can_auto_update' => $canAutoUpdate,
'update_blockers' => $updateBlockers,
'check_enabled' => $this->privacySettings->checkForUpdates,
];
}
/**
* Get releases newer than the current version.
* @return array<array{version: string, tag: string, name: string, url: string, published_at: string, body: string, prerelease: bool, assets: array}>
*/
public function getAvailableUpdates(bool $includePrerelease = false): array
{
$releases = $this->getAvailableReleases();
$current = $this->getCurrentVersion();
$updates = [];
foreach ($releases as $release) {
if ($release['draft']) {
continue;
}
if (!$includePrerelease && $release['prerelease']) {
continue;
}
try {
$releaseVersion = Version::fromString($release['version']);
if ($releaseVersion->isGreaterThan($current)) {
$updates[] = $release;
}
} catch (\Exception) {
continue;
}
}
return $updates;
}
}

View file

@ -0,0 +1,940 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\System;
use Psr\Log\LoggerInterface;
use Shivas\VersioningBundle\Service\VersionManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Process;
/**
* Handles the execution of Part-DB updates with safety mechanisms.
*
* This service should primarily be used from CLI commands, not web requests,
* due to the long-running nature of updates and permission requirements.
*
* For web requests, use startBackgroundUpdate() method.
*/
class UpdateExecutor
{
private const LOCK_FILE = 'var/update.lock';
private const MAINTENANCE_FILE = 'var/maintenance.flag';
private const UPDATE_LOG_DIR = 'var/log/updates';
private const PROGRESS_FILE = 'var/update_progress.json';
/** @var array<array{step: string, message: string, success: bool, timestamp: string, duration: ?float}> */
private array $steps = [];
private ?string $currentLogFile = null;
public function __construct(
#[Autowire(param: 'kernel.project_dir')]
private readonly string $project_dir,
private readonly LoggerInterface $logger,
private readonly Filesystem $filesystem,
private readonly InstallationTypeDetector $installationTypeDetector,
private readonly UpdateChecker $updateChecker,
private readonly BackupManager $backupManager,
private readonly CommandRunHelper $commandRunHelper,
#[Autowire(param: 'app.debug_mode')]
private readonly bool $debugMode = false,
) {
}
/**
* Get the current version string for use in filenames.
*/
private function getCurrentVersionString(): string
{
return $this->updateChecker->getCurrentVersionString();
}
/**
* Check if an update is currently in progress.
*/
public function isLocked(): bool
{
// Check if lock is stale (older than 1 hour)
$lockData = $this->getLockInfo();
if ($lockData === null) {
return false;
}
if ($lockData && isset($lockData['started_at'])) {
$startedAt = new \DateTime($lockData['started_at']);
$now = new \DateTime();
$diff = $now->getTimestamp() - $startedAt->getTimestamp();
// If lock is older than 1 hour, consider it stale
if ($diff > 3600) {
$this->logger->warning('Found stale update lock, removing it');
$this->releaseLock();
return false;
}
}
return true;
}
/**
* Get lock information, or null if not locked.
* @return null|array{started_at: string, pid: int, user: string}
*/
public function getLockInfo(): ?array
{
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
if (!file_exists($lockFile)) {
return null;
}
return json_decode(file_get_contents($lockFile), true, 512, JSON_THROW_ON_ERROR);
}
/**
* Check if maintenance mode is enabled.
*/
public function isMaintenanceMode(): bool
{
return file_exists($this->project_dir . '/' . self::MAINTENANCE_FILE);
}
/**
* Get maintenance mode information.
* @return null|array{enabled_at: string, reason: string}
*/
public function getMaintenanceInfo(): ?array
{
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
if (!file_exists($maintenanceFile)) {
return null;
}
return json_decode(file_get_contents($maintenanceFile), true, 512, JSON_THROW_ON_ERROR);
}
/**
* Acquire an exclusive lock for the update process.
*/
public function acquireLock(): bool
{
if ($this->isLocked()) {
return false;
}
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
$lockDir = dirname($lockFile);
if (!is_dir($lockDir)) {
$this->filesystem->mkdir($lockDir);
}
$lockData = [
'started_at' => (new \DateTime())->format('c'),
'pid' => getmypid(),
'user' => get_current_user(),
];
$this->filesystem->dumpFile($lockFile, json_encode($lockData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
return true;
}
/**
* Release the update lock.
*/
public function releaseLock(): void
{
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
if (file_exists($lockFile)) {
$this->filesystem->remove($lockFile);
}
}
/**
* Enable maintenance mode to block user access during update.
*/
public function enableMaintenanceMode(string $reason = 'Update in progress'): void
{
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
$maintenanceDir = dirname($maintenanceFile);
if (!is_dir($maintenanceDir)) {
$this->filesystem->mkdir($maintenanceDir);
}
$data = [
'enabled_at' => (new \DateTime())->format('c'),
'reason' => $reason,
];
$this->filesystem->dumpFile($maintenanceFile, json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
}
/**
* Disable maintenance mode.
*/
public function disableMaintenanceMode(): void
{
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
if (file_exists($maintenanceFile)) {
$this->filesystem->remove($maintenanceFile);
}
}
/**
* Validate that we can perform an update.
*
* @return array{valid: bool, errors: array<string>}
*/
public function validateUpdatePreconditions(): array
{
$errors = [];
// Check installation type
$installType = $this->installationTypeDetector->detect();
if (!$installType->supportsAutoUpdate()) {
$errors[] = sprintf(
'Installation type "%s" does not support automatic updates. %s',
$installType->getLabel(),
$installType->getUpdateInstructions()
);
}
// Check for Git installation
if ($installType === InstallationType::GIT) {
// Check if git is available
$process = new Process(['git', '--version']);
$process->run();
if (!$process->isSuccessful()) {
$errors[] = 'Git command not found. Please ensure Git is installed and in PATH.';
}
// Check for local changes
$process = new Process(['git', 'status', '--porcelain'], $this->project_dir);
$process->run();
if (!empty(trim($process->getOutput()))) {
$errors[] = 'There are uncommitted local changes. Please commit or stash them before updating.';
}
}
// Check if composer is available
$process = new Process(['composer', '--version']);
$process->run();
if (!$process->isSuccessful()) {
$errors[] = 'Composer command not found. Please ensure Composer is installed and in PATH.';
}
// Check if PHP CLI is available
$process = new Process(['php', '--version']);
$process->run();
if (!$process->isSuccessful()) {
$errors[] = 'PHP CLI not found. Please ensure PHP is installed and in PATH.';
}
// Check if yarn is available (for frontend assets)
$process = new Process(['yarn', '--version']);
$process->run();
if (!$process->isSuccessful()) {
$errors[] = 'Yarn command not found. Please ensure Yarn is installed and in PATH for frontend asset compilation.';
}
// Check write permissions
$testDirs = ['var', 'vendor', 'public'];
foreach ($testDirs as $dir) {
$fullPath = $this->project_dir . '/' . $dir;
if (is_dir($fullPath) && !is_writable($fullPath)) {
$errors[] = sprintf('Directory "%s" is not writable.', $dir);
}
}
// Check if already locked
if ($this->isLocked()) {
$lockInfo = $this->getLockInfo();
$errors[] = sprintf(
'An update is already in progress (started at %s).',
$lockInfo['started_at'] ?? 'unknown time'
);
}
return [
'valid' => empty($errors),
'errors' => $errors,
];
}
/**
* Execute the update to a specific version.
*
* @param string $targetVersion The target version/tag to update to (e.g., "v2.6.0")
* @param bool $createBackup Whether to create a backup before updating
* @param callable|null $onProgress Callback for progress updates
*
* @return array{success: bool, steps: array, rollback_tag: ?string, error: ?string, log_file: ?string}
*/
public function executeUpdate(
string $targetVersion,
bool $createBackup = true,
?callable $onProgress = null
): array {
$this->steps = [];
$rollbackTag = null;
$startTime = microtime(true);
// Initialize log file
$this->initializeLogFile($targetVersion);
$log = function (string $step, string $message, bool $success = true, ?float $duration = null) use ($onProgress): void {
$entry = [
'step' => $step,
'message' => $message,
'success' => $success,
'timestamp' => (new \DateTime())->format('c'),
'duration' => $duration,
];
$this->steps[] = $entry;
$this->writeToLogFile($entry);
$this->logger->info("Update [{$step}]: {$message}", ['success' => $success]);
if ($onProgress) {
$onProgress($entry);
}
};
try {
// Validate preconditions
$validation = $this->validateUpdatePreconditions();
if (!$validation['valid']) {
throw new \RuntimeException('Precondition check failed: ' . implode('; ', $validation['errors']));
}
// Step 1: Acquire lock
$stepStart = microtime(true);
if (!$this->acquireLock()) {
throw new \RuntimeException('Could not acquire update lock. Another update may be in progress.');
}
$log('lock', 'Acquired exclusive update lock', true, microtime(true) - $stepStart);
// Step 2: Enable maintenance mode
$stepStart = microtime(true);
$this->enableMaintenanceMode('Updating to ' . $targetVersion);
$log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart);
// Step 3: Create rollback point with version info
$stepStart = microtime(true);
$currentVersion = $this->getCurrentVersionString();
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
$rollbackTag = 'pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His');
$this->runCommand(['git', 'tag', $rollbackTag], 'Create rollback tag');
$log('rollback_tag', 'Created rollback tag: ' . $rollbackTag, true, microtime(true) - $stepStart);
// Step 4: Create backup (optional)
if ($createBackup) {
$stepStart = microtime(true);
$backupFile = $this->backupManager->createBackup($targetVersion);
$log('backup', 'Created backup: ' . basename($backupFile), true, microtime(true) - $stepStart);
}
// Step 5: Fetch from remote
$stepStart = microtime(true);
$this->runCommand(['git', 'fetch', '--tags', '--force', 'origin'], 'Fetch from origin', 120);
$log('fetch', 'Fetched latest changes and tags from origin', true, microtime(true) - $stepStart);
// Step 6: Checkout target version
$stepStart = microtime(true);
$this->runCommand(['git', 'checkout', $targetVersion], 'Checkout version');
$log('checkout', 'Checked out version: ' . $targetVersion, true, microtime(true) - $stepStart);
// Step 7: Install PHP dependencies
$stepStart = microtime(true);
if ($this->debugMode) {
$this->runCommand([ // Install with dev dependencies in debug mode
'composer',
'install',
'--no-interaction',
'--no-progress',
], 'Install PHP dependencies', 600);
} else {
$this->runCommand([
'composer',
'install',
'--no-dev',
'--optimize-autoloader',
'--no-interaction',
'--no-progress',
], 'Install PHP dependencies', 600);
}
$log('composer', 'Installed/updated PHP dependencies', true, microtime(true) - $stepStart);
// Step 8: Install frontend dependencies
$stepStart = microtime(true);
$this->runCommand([
'yarn', 'install',
'--frozen-lockfile',
'--non-interactive',
], 'Install frontend dependencies', 600);
$log('yarn_install', 'Installed frontend dependencies', true, microtime(true) - $stepStart);
// Step 9: Build frontend assets
$stepStart = microtime(true);
$this->runCommand([
'yarn', 'build',
], 'Build frontend assets', 600);
$log('yarn_build', 'Built frontend assets', true, microtime(true) - $stepStart);
// Step 10: Run database migrations
$stepStart = microtime(true);
$this->runCommand([
'php', 'bin/console', 'doctrine:migrations:migrate',
'--no-interaction',
'--allow-no-migration',
], 'Run migrations', 300);
$log('migrations', 'Database migrations completed', true, microtime(true) - $stepStart);
// Step 11: Clear cache
$stepStart = microtime(true);
$this->runCommand([
'php', 'bin/console', 'cache:clear',
'--env=prod',
'--no-interaction',
], 'Clear cache', 120);
$log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart);
// Step 12: Warm up cache
$stepStart = microtime(true);
$this->runCommand([
'php', 'bin/console', 'cache:warmup',
'--env=prod',
], 'Warmup cache', 120);
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
// Step 13: Disable maintenance mode
$stepStart = microtime(true);
$this->disableMaintenanceMode();
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
// Step 14: Release lock
$stepStart = microtime(true);
$this->releaseLock();
$totalDuration = microtime(true) - $startTime;
$log('complete', sprintf('Update completed successfully in %.1f seconds', $totalDuration), true, microtime(true) - $stepStart);
return [
'success' => true,
'steps' => $this->steps,
'rollback_tag' => $rollbackTag,
'error' => null,
'log_file' => $this->currentLogFile,
'duration' => $totalDuration,
];
} catch (\Exception $e) {
$log('error', 'Update failed: ' . $e->getMessage(), false);
// Attempt rollback
if ($rollbackTag) {
try {
$this->runCommand(['git', 'checkout', $rollbackTag], 'Rollback');
$log('rollback', 'Rolled back to: ' . $rollbackTag, true);
// Re-run composer install after rollback
$this->runCommand([
'composer', 'install',
'--no-dev',
'--optimize-autoloader',
'--no-interaction',
], 'Reinstall dependencies after rollback', 600);
$log('rollback_composer', 'Reinstalled PHP dependencies after rollback', true);
// Re-run yarn install after rollback
$this->runCommand([
'yarn', 'install',
'--frozen-lockfile',
'--non-interactive',
], 'Reinstall frontend dependencies after rollback', 600);
$log('rollback_yarn_install', 'Reinstalled frontend dependencies after rollback', true);
// Re-run yarn build after rollback
$this->runCommand([
'yarn', 'build',
], 'Rebuild frontend assets after rollback', 600);
$log('rollback_yarn_build', 'Rebuilt frontend assets after rollback', true);
// Clear cache after rollback
$this->runCommand([
'php', 'bin/console', 'cache:clear',
'--env=prod',
], 'Clear cache after rollback', 120);
$log('rollback_cache', 'Cleared cache after rollback', true);
} catch (\Exception $rollbackError) {
$log('rollback_failed', 'Rollback failed: ' . $rollbackError->getMessage(), false);
}
}
// Clean up
$this->disableMaintenanceMode();
$this->releaseLock();
return [
'success' => false,
'steps' => $this->steps,
'rollback_tag' => $rollbackTag,
'error' => $e->getMessage(),
'log_file' => $this->currentLogFile,
'duration' => microtime(true) - $startTime,
];
}
}
/**
* Run a shell command with proper error handling.
*/
private function runCommand(array $command, string $description, int $timeout = 120): string
{
return $this->commandRunHelper->runCommand($command, $description, $timeout);
}
/**
* Initialize the log file for this update.
*/
private function initializeLogFile(string $targetVersion): void
{
$logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR;
if (!is_dir($logDir)) {
$this->filesystem->mkdir($logDir, 0755);
}
// Include version numbers in log filename: update-v2.5.1-to-v2.6.0-2024-01-30-185400.log
$currentVersion = $this->getCurrentVersionString();
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
$this->currentLogFile = $logDir . '/update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.log';
$header = sprintf(
"Part-DB Update Log\n" .
"==================\n" .
"Started: %s\n" .
"From Version: %s\n" .
"Target Version: %s\n" .
"==================\n\n",
date('Y-m-d H:i:s'),
$currentVersion,
$targetVersion
);
file_put_contents($this->currentLogFile, $header);
}
/**
* Write an entry to the log file.
*/
private function writeToLogFile(array $entry): void
{
if (!$this->currentLogFile) {
return;
}
$line = sprintf(
"[%s] %s: %s%s\n",
$entry['timestamp'],
strtoupper($entry['step']),
$entry['message'],
$entry['duration'] ? sprintf(' (%.2fs)', $entry['duration']) : ''
);
file_put_contents($this->currentLogFile, $line, FILE_APPEND);
}
/**
* Get list of update log files.
* @return array{file: string, path: string, date: int, size: int}[]
*/
public function getUpdateLogs(): array
{
$logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR;
if (!is_dir($logDir)) {
return [];
}
$logs = [];
foreach (glob($logDir . '/update-*.log') as $logFile) {
$logs[] = [
'file' => basename($logFile),
'path' => $logFile,
'date' => filemtime($logFile),
'size' => filesize($logFile),
];
}
// Sort by date descending
usort($logs, static fn($a, $b) => $b['date'] <=> $a['date']);
return $logs;
}
/**
* Restore from a backup file with maintenance mode and cache clearing.
*
* This wraps BackupManager::restoreBackup with additional safety measures
* like lock acquisition, maintenance mode, and cache operations.
*
* @param string $filename The backup filename to restore
* @param bool $restoreDatabase Whether to restore the database
* @param bool $restoreConfig Whether to restore config files
* @param bool $restoreAttachments Whether to restore attachments
* @param callable|null $onProgress Callback for progress updates
* @return array{success: bool, steps: array, error: ?string}
*/
public function restoreBackup(
string $filename,
bool $restoreDatabase = true,
bool $restoreConfig = false,
bool $restoreAttachments = false,
?callable $onProgress = null
): array {
$this->steps = [];
$startTime = microtime(true);
$log = function (string $step, string $message, bool $success, ?float $duration = null) use ($onProgress): void {
$entry = [
'step' => $step,
'message' => $message,
'success' => $success,
'timestamp' => (new \DateTime())->format('c'),
'duration' => $duration,
];
$this->steps[] = $entry;
$this->logger->info('[Restore] ' . $step . ': ' . $message, ['success' => $success]);
if ($onProgress) {
$onProgress($entry);
}
};
try {
$stepStart = microtime(true);
// Step 1: Acquire lock
if (!$this->acquireLock()) {
throw new \RuntimeException('Could not acquire lock. Another operation may be in progress.');
}
$log('lock', 'Acquired exclusive restore lock', true, microtime(true) - $stepStart);
// Step 2: Enable maintenance mode
$stepStart = microtime(true);
$this->enableMaintenanceMode('Restoring from backup...');
$log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart);
// Step 3: Delegate to BackupManager for core restoration
$stepStart = microtime(true);
$result = $this->backupManager->restoreBackup(
$filename,
$restoreDatabase,
$restoreConfig,
$restoreAttachments,
function ($entry) use ($log) {
// Forward progress from BackupManager
$log($entry['step'], $entry['message'], $entry['success'], $entry['duration'] ?? null);
}
);
if (!$result['success']) {
throw new \RuntimeException($result['error'] ?? 'Restore failed');
}
// Step 4: Clear cache
$stepStart = microtime(true);
$this->runCommand(['php', 'bin/console', 'cache:clear', '--no-warmup'], 'Clear cache');
$log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart);
// Step 5: Warm up cache
$stepStart = microtime(true);
$this->runCommand(['php', 'bin/console', 'cache:warmup'], 'Warm up cache');
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
// Step 6: Disable maintenance mode
$stepStart = microtime(true);
$this->disableMaintenanceMode();
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
// Step 7: Release lock
$this->releaseLock();
$totalDuration = microtime(true) - $startTime;
$log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true);
return [
'success' => true,
'steps' => $this->steps,
'error' => null,
];
} catch (\Throwable $e) {
$this->logger->error('Restore failed: ' . $e->getMessage(), [
'exception' => $e,
'file' => $filename,
]);
// Try to clean up
try {
$this->disableMaintenanceMode();
$this->releaseLock();
} catch (\Throwable $cleanupError) {
$this->logger->error('Cleanup after failed restore also failed', ['error' => $cleanupError->getMessage()]);
}
return [
'success' => false,
'steps' => $this->steps,
'error' => $e->getMessage(),
];
}
}
/**
* Get the path to the progress file.
*/
public function getProgressFilePath(): string
{
return $this->project_dir . '/' . self::PROGRESS_FILE;
}
/**
* Save progress to file for web UI polling.
* @param array{status: string, target_version: string, create_backup: bool, started_at: string, current_step: int, total_steps: int, step_name: string, step_message: string, steps: array, error: ?string} $progress
*/
private function saveProgress(array $progress): void
{
$progressFile = $this->getProgressFilePath();
$progressDir = dirname($progressFile);
if (!is_dir($progressDir)) {
$this->filesystem->mkdir($progressDir);
}
$this->filesystem->dumpFile($progressFile, json_encode($progress, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
}
/**
* Get current update progress from file.
* @return null|array{status: string, target_version: string, create_backup: bool, started_at: string, current_step: int, total_steps: int, step_name: string, step_message: string, steps: array, error: ?string}
*/
public function getProgress(): ?array
{
$progressFile = $this->getProgressFilePath();
if (!file_exists($progressFile)) {
return null;
}
$data = json_decode(file_get_contents($progressFile), true, 512, JSON_THROW_ON_ERROR);
// If the progress file is stale (older than 30 minutes), consider it invalid
if ($data && isset($data['started_at'])) {
$startedAt = strtotime($data['started_at']);
if (time() - $startedAt > 1800) {
$this->clearProgress();
return null;
}
}
return $data;
}
/**
* Clear progress file.
*/
public function clearProgress(): void
{
$progressFile = $this->getProgressFilePath();
if (file_exists($progressFile)) {
$this->filesystem->remove($progressFile);
}
}
/**
* Check if an update is currently running (based on progress file).
*/
public function isUpdateRunning(): bool
{
$progress = $this->getProgress();
if (!$progress) {
return false;
}
return isset($progress['status']) && $progress['status'] === 'running';
}
/**
* Start the update process in the background.
* Returns the process ID or null on failure.
*/
public function startBackgroundUpdate(string $targetVersion, bool $createBackup = true): ?int
{
// Validate first
$validation = $this->validateUpdatePreconditions();
if (!$validation['valid']) {
$this->logger->error('Update validation failed', ['errors' => $validation['errors']]);
return null;
}
// Initialize progress file
$this->saveProgress([
'status' => 'starting',
'target_version' => $targetVersion,
'create_backup' => $createBackup,
'started_at' => (new \DateTime())->format('c'),
'current_step' => 0,
'total_steps' => 14,
'step_name' => 'initializing',
'step_message' => 'Starting update process...',
'steps' => [],
'error' => null,
]);
// Build the command to run in background
// Use 'php' from PATH as PHP_BINARY might point to php-fpm
$consolePath = $this->project_dir . '/bin/console';
$logFile = $this->project_dir . '/var/log/update-background.log';
// Ensure log directory exists
$logDir = dirname($logFile);
if (!is_dir($logDir)) {
$this->filesystem->mkdir($logDir, 0755);
}
//If we are on Windows, we cannot use nohup
if (PHP_OS_FAMILY === 'Windows') {
$command = sprintf(
'start /B php %s partdb:update %s %s --force --no-interaction >> %s 2>&1',
escapeshellarg($consolePath),
escapeshellarg($targetVersion),
$createBackup ? '' : '--no-backup',
escapeshellarg($logFile)
);
} else { //Unix like platforms should be able to use nohup
// Use nohup to properly detach the process from the web request
// The process will continue running even after the PHP request ends
$command = sprintf(
'nohup php %s partdb:update %s %s --force --no-interaction >> %s 2>&1 &',
escapeshellarg($consolePath),
escapeshellarg($targetVersion),
$createBackup ? '' : '--no-backup',
escapeshellarg($logFile)
);
}
$this->logger->info('Starting background update', [
'command' => $command,
'target_version' => $targetVersion,
]);
// Execute in background using shell_exec for proper detachment
// shell_exec with & runs the command in background
//@php-ignore-next-line We really need to use shell_exec here
$output = shell_exec($command);
// Give it a moment to start
usleep(500000); // 500ms
// Check if progress file was updated (indicates process started)
$progress = $this->getProgress();
if ($progress && isset($progress['status'])) {
$this->logger->info('Background update started successfully');
return 1; // Return a non-null value to indicate success
}
$this->logger->error('Background update may not have started', ['output' => $output]);
return 1; // Still return success as the process might just be slow to start
}
/**
* Execute update with progress file updates for web UI.
* This is called by the CLI command and updates the progress file.
*/
public function executeUpdateWithProgress(
string $targetVersion,
bool $createBackup = true,
?callable $onProgress = null
): array {
$totalSteps = 12;
$currentStep = 0;
$updateProgress = function (string $stepName, string $message, bool $success = true) use (&$currentStep, $totalSteps, $targetVersion, $createBackup): void {
$currentStep++;
$progress = $this->getProgress() ?? [
'status' => 'running',
'target_version' => $targetVersion,
'create_backup' => $createBackup,
'started_at' => (new \DateTime())->format('c'),
'steps' => [],
];
$progress['current_step'] = $currentStep;
$progress['total_steps'] = $totalSteps;
$progress['step_name'] = $stepName;
$progress['step_message'] = $message;
$progress['status'] = 'running';
$progress['steps'][] = [
'step' => $stepName,
'message' => $message,
'success' => $success,
'timestamp' => (new \DateTime())->format('c'),
];
$this->saveProgress($progress);
};
// Wrap the existing executeUpdate with progress tracking
$result = $this->executeUpdate($targetVersion, $createBackup, function ($entry) use ($updateProgress, $onProgress) {
$updateProgress($entry['step'], $entry['message'], $entry['success']);
if ($onProgress) {
$onProgress($entry);
}
});
// Update final status
$finalProgress = $this->getProgress() ?? [];
$finalProgress['status'] = $result['success'] ? 'completed' : 'failed';
$finalProgress['completed_at'] = (new \DateTime())->format('c');
$finalProgress['result'] = $result;
$finalProgress['error'] = $result['error'];
$this->saveProgress($finalProgress);
return $result;
}
}

View file

@ -325,6 +325,13 @@ class ToolsTreeBuilder
))->setIcon('fa fa-fw fa-gears fa-solid');
}
if ($this->security->isGranted('@system.show_updates')) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.system.update_manager'),
$this->urlGenerator->generate('admin_update_manager')
))->setIcon('fa-fw fa-treeview fa-solid fa-arrow-circle-up');
}
return $nodes;
}
}

View file

@ -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);
}

View file

@ -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,

View file

@ -0,0 +1,79 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Twig;
use App\Services\System\UpdateAvailableFacade;
use Symfony\Bundle\SecurityBundle\Security;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* Twig extension for update-related functions.
*/
final class UpdateExtension extends AbstractExtension
{
public function __construct(private readonly UpdateAvailableFacade $updateAvailableManager,
private readonly Security $security)
{
}
public function getFunctions(): array
{
return [
new TwigFunction('is_update_available', $this->isUpdateAvailable(...)),
new TwigFunction('get_latest_version', $this->getLatestVersion(...)),
new TwigFunction('get_latest_version_url', $this->getLatestVersionUrl(...)),
];
}
/**
* Check if an update is available and the user has permission to see it.
*/
public function isUpdateAvailable(): bool
{
// Only show to users with the show_updates permission
if (!$this->security->isGranted('@system.show_updates')) {
return false;
}
return $this->updateAvailableManager->isUpdateAvailable();
}
/**
* Get the latest available version string.
*/
public function getLatestVersion(): string
{
return $this->updateAvailableManager->getLatestVersionString();
}
/**
* Get the URL to the latest version release page.
*/
public function getLatestVersionUrl(): string
{
return $this->updateAvailableManager->getLatestVersionUrl();
}
}

View file

@ -82,6 +82,19 @@
<ul class="navbar-nav ms-3" id="login-content">
{# Update notification badge #}
{% if is_update_available() %}
<li class="nav-item me-2">
<a href="{{ path('admin_update_manager') }}" class="nav-link position-relative"
title="{% trans %}update_manager.new_version_available.title{% endtrans %}: {{ get_latest_version() }}">
<i class="fas fa-arrow-circle-up text-success"></i>
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-success" style="font-size: 0.6rem;">
{% trans %}update_manager.new{% endtrans %}
</span>
</a>
</li>
{% endif %}
<li class="nav-item dropdown">
<a href="#" class="dropdown-toggle link-anchor nav-link" data-bs-toggle="dropdown" role="button"
aria-haspopup="true" aria-expanded="false" id="navbar-user-dropdown-btn" data-bs-reference="window">

View file

@ -0,0 +1,423 @@
{% extends "main_card.html.twig" %}
{% block title %}Part-DB {% trans %}update_manager.title{% endtrans %}{% endblock %}
{% block card_title %}
<i class="fas fa-arrow-circle-up"></i> Part-DB {% trans %}update_manager.title{% endtrans %}
{% endblock %}
{% block card_content %}
<div>
{# Maintenance Mode Warning #}
{% if is_maintenance %}
<div class="alert alert-warning" role="alert">
<i class="fas fa-tools me-2"></i>
<strong>{% trans %}update_manager.maintenance_mode_active{% endtrans %}</strong>
{% if maintenance_info.reason is defined %}
- {{ maintenance_info.reason }}
{% endif %}
</div>
{% endif %}
{# Lock Warning #}
{% if is_locked %}
<div class="alert alert-info" role="alert">
<i class="fas fa-lock me-2"></i>
<strong>{% trans %}update_manager.update_in_progress{% endtrans %}</strong>
{% if lock_info.started_at is defined %}
({% trans %}update_manager.started_at{% endtrans %}: {{ lock_info.started_at }})
{% endif %}
<a href="{{ path('admin_update_manager_progress') }}" class="alert-link ms-2">
{% trans %}update_manager.view_progress{% endtrans %}
</a>
</div>
{% endif %}
{# Web Updates Disabled Warning #}
{% if web_updates_disabled %}
<div class="alert alert-secondary" role="alert">
<i class="fas fa-ban me-2"></i>
<strong>{% trans %}update_manager.web_updates_disabled{% endtrans %}</strong>
<p class="mb-0 mt-2">{% trans %}update_manager.web_updates_disabled_hint{% endtrans %}</p>
</div>
{% endif %}
{# Backup Restore Disabled Warning #}
{% if backup_restore_disabled %}
<div class="alert alert-secondary" role="alert">
<i class="fas fa-ban me-2"></i>
<strong>{% trans %}update_manager.backup_restore_disabled{% endtrans %}</strong>
</div>
{% endif %}
<div class="row">
{# Current Version Card #}
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-info-circle me-2"></i>{% trans %}update_manager.current_installation{% endtrans %}
</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tbody>
<tr>
<th scope="row" style="width: 40%">{% trans %}update_manager.version{% endtrans %}</th>
<td>
<span class="badge bg-primary fs-6">{{ status.current_version }}</span>
</td>
</tr>
<tr>
<th scope="row">{% trans %}update_manager.installation_type{% endtrans %}</th>
<td>
<span class="badge bg-secondary">{{ status.installation.type_name }}</span>
</td>
</tr>
{% if status.git.is_git_install %}
<tr>
<th scope="row">{% trans %}update_manager.git_branch{% endtrans %}</th>
<td><code>{{ status.git.branch ?? 'N/A' }}</code></td>
</tr>
<tr>
<th scope="row">{% trans %}update_manager.git_commit{% endtrans %}</th>
<td><code>{{ status.git.commit ?? 'N/A' }}</code></td>
</tr>
<tr>
<th scope="row">{% trans %}update_manager.local_changes{% endtrans %}</th>
<td>
{% if status.git.has_local_changes %}
<span class="badge bg-warning text-dark">
<i class="fas fa-exclamation-triangle me-1"></i>{% trans %}Yes{% endtrans %}
</span>
{% else %}
<span class="badge bg-success">
<i class="fas fa-check me-1"></i>{% trans %}No{% endtrans %}
</span>
{% endif %}
</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans %}update_manager.auto_update_supported{% endtrans %}</th>
<td>
{% if status.can_auto_update %}
<span class="badge bg-success">
<i class="fas fa-check me-1"></i>{% trans %}Yes{% endtrans %}
</span>
{% else %}
<span class="badge bg-secondary">
<i class="fas fa-times me-1"></i>{% trans %}No{% endtrans %}
</span>
{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
<div class="card-footer">
<form action="{{ path('admin_update_manager_refresh') }}" method="post" class="d-inline">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_refresh') }}">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-sync-alt me-1"></i> {% trans %}update_manager.refresh{% endtrans %}
</button>
</form>
</div>
</div>
</div>
{# Latest Version / Update Card #}
<div class="col-lg-6 mb-4">
<div class="card h-100 {{ status.update_available ? 'border-success' : '' }}">
<div class="card-header {{ status.update_available ? 'bg-success text-white' : '' }}">
{% if status.update_available %}
<i class="fas fa-gift me-2"></i>{% trans %}update_manager.new_version_available.title{% endtrans %}
{% else %}
<i class="fas fa-check-circle me-2"></i>{% trans %}update_manager.latest_release{% endtrans %}
{% endif %}
</div>
<div class="card-body">
{% if status.latest_version %}
<div class="text-center mb-3">
<span class="badge bg-{{ status.update_available ? 'success' : 'primary' }} fs-4 px-4 py-2">
{{ status.latest_tag }}
</span>
{% if not status.update_available %}
<p class="text-success mt-2 mb-0">
<i class="fas fa-check-circle me-1"></i>
{% trans %}update_manager.already_up_to_date{% endtrans %}
</p>
{% endif %}
</div>
{% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %}
<form action="{{ path('admin_update_manager_start') }}" method="post"
data-controller="update-confirm"
data-update-confirm-is-downgrade-value="false"
data-update-confirm-target-version-value="{{ status.latest_tag }}"
data-update-confirm-confirm-update-value="{{ 'update_manager.confirm_update'|trans }}"
data-update-confirm-confirm-downgrade-value="{{ 'update_manager.confirm_downgrade'|trans }}"
data-update-confirm-downgrade-warning-value="{{ 'update_manager.downgrade_removes_update_manager'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start') }}">
<input type="hidden" name="version" value="{{ status.latest_tag }}">
<div class="d-grid gap-2">
<button type="submit" class="btn btn-success btn-lg">
<i class="fas fa-download me-2"></i>
{% trans %}update_manager.update_to{% endtrans %} {{ status.latest_tag }}
</button>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="backup" value="1" id="create-backup" checked>
<label class="form-check-label" for="create-backup">
<i class="fas fa-database me-1"></i> {% trans %}update_manager.create_backup{% endtrans %}
</label>
</div>
</form>
{% endif %}
{% if status.published_at %}
<p class="text-muted small mt-3 mb-0">
<i class="fas fa-calendar me-1"></i>
{% trans %}update_manager.released{% endtrans %}: {{ status.published_at|date('Y-m-d') }}
</p>
{% endif %}
{% else %}
<div class="text-center text-muted py-4">
<i class="fas fa-question-circle fa-3x mb-3"></i>
<p>{% trans %}update_manager.could_not_fetch_releases{% endtrans %}</p>
</div>
{% endif %}
</div>
{% if status.latest_tag %}
<div class="card-footer">
<a href="{{ path('admin_update_manager_release', {tag: status.latest_tag}) }}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-file-alt me-1"></i> {% trans %}update_manager.view_release_notes{% endtrans %}
</a>
{% if status.release_url %}
<a href="{{ status.release_url }}" class="btn btn-outline-secondary btn-sm" target="_blank">
<i class="fab fa-github me-1"></i> GitHub
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
{# Validation Issues #}
{% if not validation.valid %}
<div class="alert alert-warning" role="alert">
<h6 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>{% trans %}update_manager.validation_issues{% endtrans %}
</h6>
<ul class="mb-0">
{% for error in validation.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# Non-auto-update installations info #}
{% if not status.can_auto_update %}
<div class="alert alert-secondary">
<h6 class="alert-heading">
<i class="fas fa-info-circle me-2"></i>{% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }}
</h6>
<p class="mb-0">{{ status.installation.update_instructions }}</p>
</div>
{% endif %}
<div class="row">
{# Available Versions #}
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-tags me-2"></i>{% trans %}update_manager.available_versions{% endtrans %}
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="sticky-top" style="background-color: #f8f9fa;">
<tr>
<th>{% trans %}update_manager.version{% endtrans %}</th>
<th>{% trans %}update_manager.released{% endtrans %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for release in all_releases %}
<tr{% if release.version == status.current_version %} class="table-active"{% endif %}>
<td>
<code>{{ release.tag }}</code>
{% if release.prerelease %}
<span class="badge bg-warning text-dark ms-1">pre</span>
{% endif %}
{% if release.version == status.current_version %}
<span class="badge bg-primary ms-1">{% trans %}update_manager.current{% endtrans %}</span>
{% endif %}
</td>
<td class="text-muted small">
{{ release.published_at|date('Y-m-d') }}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="{{ path('admin_update_manager_release', {tag: release.tag}) }}"
class="btn btn-outline-secondary"
title="{% trans %}update_manager.view_release_notes{% endtrans %}">
<i class="fas fa-file-alt"></i>
</a>
{% if release.version != status.current_version and status.can_auto_update and validation.valid and not web_updates_disabled %}
<form action="{{ path('admin_update_manager_start') }}" method="post" class="d-inline"
data-controller="update-confirm"
data-update-confirm-is-downgrade-value="{{ release.version < status.current_version ? 'true' : 'false' }}"
data-update-confirm-target-version-value="{{ release.tag }}"
data-update-confirm-confirm-update-value="{{ 'update_manager.confirm_update'|trans }}"
data-update-confirm-confirm-downgrade-value="{{ 'update_manager.confirm_downgrade'|trans }}"
data-update-confirm-downgrade-warning-value="{{ 'update_manager.downgrade_removes_update_manager'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start') }}">
<input type="hidden" name="version" value="{{ release.tag }}">
<input type="hidden" name="backup" value="1">
<button type="submit"
class="btn btn-{{ release.version > status.current_version ? 'outline-success' : 'outline-warning' }}"
title="{% trans %}update_manager.switch_to{% endtrans %} {{ release.tag }}">
{% if release.version > status.current_version %}
<i class="fas fa-arrow-up"></i>
{% else %}
<i class="fas fa-arrow-down"></i>
{% endif %}
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="text-center text-muted py-3">
{% trans %}update_manager.no_releases_found{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{# Update History & Backups #}
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#logs-tab" type="button">
<i class="fas fa-history me-1"></i>{% trans %}update_manager.update_logs{% endtrans %}
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#backups-tab" type="button">
<i class="fas fa-archive me-1"></i>{% trans %}update_manager.backups{% endtrans %}
</button>
</li>
</ul>
</div>
<div class="card-body p-0">
<div class="tab-content">
<div class="tab-pane fade show active" id="logs-tab">
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="sticky-top" style="background-color: #f8f9fa;">
<tr>
<th>{% trans %}update_manager.date{% endtrans %}</th>
<th>{% trans %}update_manager.log_file{% endtrans %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for log in update_logs %}
<tr>
<td class="text-muted small">
{{ log.date|date('Y-m-d H:i') }}
</td>
<td><code class="small">{{ log.file }}</code></td>
<td>
<a href="{{ path('admin_update_manager_log', {filename: log.file}) }}"
class="btn btn-sm btn-outline-secondary">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="text-center text-muted py-3">
{% trans %}update_manager.no_logs_found{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="backups-tab">
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="sticky-top" style="background-color: #f8f9fa;">
<tr>
<th>{% trans %}update_manager.date{% endtrans %}</th>
<th>{% trans %}update_manager.file{% endtrans %}</th>
<th>{% trans %}update_manager.size{% endtrans %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for backup in backups %}
<tr>
<td class="text-muted small">
{{ backup.date|date('Y-m-d H:i') }}
</td>
<td><code class="small">{{ backup.file }}</code></td>
<td class="text-muted small">
{{ (backup.size / 1024 / 1024)|number_format(1) }} MB
</td>
<td class="text-end">
{% if status.can_auto_update and validation.valid and not backup_restore_disabled %}
<form action="{{ path('admin_update_manager_restore') }}" method="post" class="d-inline"
data-controller="backup-restore"
data-backup-restore-filename-value="{{ backup.file }}"
data-backup-restore-date-value="{{ backup.date|date('Y-m-d H:i') }}"
data-backup-restore-confirm-title-value="{{ 'update_manager.restore_confirm_title'|trans }}"
data-backup-restore-confirm-message-value="{{ 'update_manager.restore_confirm_message'|trans }}"
data-backup-restore-confirm-warning-value="{{ 'update_manager.restore_confirm_warning'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_restore') }}">
<input type="hidden" name="filename" value="{{ backup.file }}">
<input type="hidden" name="restore_database" value="1">
<button type="submit"
class="btn btn-sm btn-outline-warning"
title="{% trans %}update_manager.restore_backup{% endtrans %}">
<i class="fas fa-undo"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-3">
{% trans %}update_manager.no_backups_found{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,40 @@
{% extends "main_card.html.twig" %}
{% block title %}{{ filename }} - {% trans %}update_manager.log_viewer{% endtrans %}{% endblock %}
{% block card_title %}
<i class="fas fa-file-code"></i> {{ filename }}
{% endblock %}
{% block card_content %}
<div class="mb-4">
<a href="{{ path('admin_update_manager') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans %}update_manager.back_to_update_manager{% endtrans %}
</a>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>
<i class="fas fa-terminal me-2"></i>{% trans %}update_manager.update_log{% endtrans %}
</span>
<span class="badge bg-secondary">{{ content|length }} {% trans %}update_manager.bytes{% endtrans %}</span>
</div>
<div class="card-body p-0">
<pre class="bg-dark text-light p-3 mb-0" style="max-height: 600px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;"><code>{{ content }}</code></pre>
</div>
</div>
<style>
pre code {
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
font-size: 0.85rem;
line-height: 1.5;
}
/* Highlight different log levels */
pre code {
color: #e0e0e0;
}
</style>
{% endblock %}

View file

@ -0,0 +1,196 @@
{% extends "main_card.html.twig" %}
{% block title %}
{% if is_downgrade|default(false) %}
{% trans %}update_manager.progress.downgrade_title{% endtrans %}
{% else %}
{% trans %}update_manager.progress.title{% endtrans %}
{% endif %}
{% endblock %}
{% block card_title %}
{% if progress and progress.status == 'running' %}
<i class="fas fa-sync-alt fa-spin"></i>
{% elseif progress and progress.status == 'completed' %}
<i class="fas fa-check-circle text-success"></i>
{% elseif progress and progress.status == 'failed' %}
<i class="fas fa-times-circle text-danger"></i>
{% else %}
<i class="fas fa-hourglass-start"></i>
{% endif %}
{% if is_downgrade|default(false) %}
{% trans %}update_manager.progress.downgrade_title{% endtrans %}
{% else %}
{% trans %}update_manager.progress.title{% endtrans %}
{% endif %}
{% endblock %}
{% block head %}
{{ parent() }}
{# Auto-refresh while update is running - also refresh when 'starting' status #}
{% if not progress or progress.status == 'running' or progress.status == 'starting' %}
<meta http-equiv="refresh" content="5">
{% endif %}
{% endblock %}
{% block card_content %}
<div id="update-progress">
{# Progress Header #}
<div class="text-center mb-4">
<div class="mb-3">
{% if progress and progress.status == 'completed' %}
<i class="fas fa-check-circle fa-3x text-success"></i>
{% elseif progress and progress.status == 'failed' %}
<i class="fas fa-times-circle fa-3x text-danger"></i>
{% else %}
<i class="fas fa-cog fa-spin fa-3x text-primary"></i>
{% endif %}
</div>
<h4>
{% if progress and progress.status == 'running' %}
{% if is_downgrade|default(false) %}
{% trans %}update_manager.progress.downgrading{% endtrans %}
{% else %}
{% trans %}update_manager.progress.updating{% endtrans %}
{% endif %}
{% elseif progress and progress.status == 'completed' %}
{% if is_downgrade|default(false) %}
{% trans %}update_manager.progress.downgrade_completed{% endtrans %}
{% else %}
{% trans %}update_manager.progress.completed{% endtrans %}
{% endif %}
{% elseif progress and progress.status == 'failed' %}
{% if is_downgrade|default(false) %}
{% trans %}update_manager.progress.downgrade_failed{% endtrans %}
{% else %}
{% trans %}update_manager.progress.failed{% endtrans %}
{% endif %}
{% else %}
{% trans %}update_manager.progress.initializing{% endtrans %}
{% endif %}
</h4>
<p class="text-muted">
{% if progress %}
{% if is_downgrade|default(false) %}
{% trans with {'%version%': progress.target_version|default('unknown')} %}update_manager.progress.downgrading_to{% endtrans %}
{% else %}
{% trans with {'%version%': progress.target_version|default('unknown')} %}update_manager.progress.updating_to{% endtrans %}
{% endif %}
{% endif %}
</p>
</div>
{# Progress Bar #}
{% set percent = progress ? ((progress.current_step|default(0) / progress.total_steps|default(12)) * 100)|round : 0 %}
{% if progress and progress.status == 'completed' %}
{% set percent = 100 %}
{% endif %}
<div class="progress mb-4" style="height: 25px;">
<div class="progress-bar {% if progress and progress.status == 'completed' %}bg-success{% elseif progress and progress.status == 'failed' %}bg-danger{% else %}progress-bar-striped progress-bar-animated{% endif %}"
role="progressbar"
style="width: {{ percent }}%"
aria-valuenow="{{ progress.current_step|default(0) }}"
aria-valuemin="0"
aria-valuemax="{{ progress.total_steps|default(12) }}">
{{ percent }}%
</div>
</div>
{# Current Step - shows what's currently being worked on #}
{% if progress and (progress.status == 'running' or progress.status == 'starting') %}
<div class="alert alert-info mb-4">
<strong>{{ progress.step_name|default('initializing')|replace({'_': ' '})|capitalize }}</strong>:
{{ progress.step_message|default('Processing...') }}
</div>
{% endif %}
{# Error Message #}
{% if progress and progress.status == 'failed' %}
<div class="alert alert-danger mb-4">
<strong>{% trans %}update_manager.progress.error{% endtrans %}:</strong>
{{ progress.error|default('An unknown error occurred') }}
</div>
{% endif %}
{# Success Message #}
{% if progress and progress.status == 'completed' %}
<div class="alert alert-success mb-4">
<i class="fas fa-check-circle me-2"></i>
{% if is_downgrade|default(false) %}
{% trans %}update_manager.progress.downgrade_success_message{% endtrans %}
{% else %}
{% trans %}update_manager.progress.success_message{% endtrans %}
{% endif %}
</div>
{% endif %}
{# Steps Timeline #}
<div class="card mb-4">
<div class="card-header">
<i class="fas fa-list-ol me-2"></i>
{% if is_downgrade|default(false) %}
{% trans %}update_manager.progress.downgrade_steps{% endtrans %}
{% else %}
{% trans %}update_manager.progress.steps{% endtrans %}
{% endif %}
</div>
<div class="card-body p-0">
<ul class="list-group list-group-flush">
{% if progress and progress.steps %}
{% for step in progress.steps %}
<li class="list-group-item d-flex align-items-center">
{% if step.success %}
<i class="fas fa-check-circle text-success me-3"></i>
{% else %}
<i class="fas fa-times-circle text-danger me-3"></i>
{% endif %}
<div class="flex-grow-1">
<strong>{{ step.step|replace({'_': ' '})|capitalize }}</strong>
<br><small class="text-muted">{{ step.message }}</small>
</div>
<small class="text-muted">{{ step.timestamp|date('H:i:s') }}</small>
</li>
{% endfor %}
{% else %}
<li class="list-group-item text-center text-muted py-3">
<i class="fas fa-clock me-2"></i>{% trans %}update_manager.progress.waiting{% endtrans %}
</li>
{% endif %}
</ul>
</div>
</div>
{# Actions #}
<div class="text-center">
{% if progress and (progress.status == 'completed' or progress.status == 'failed') %}
<a href="{{ path('admin_update_manager') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans %}update_manager.progress.back{% endtrans %}
</a>
<a href="{{ path('admin_update_manager_progress') }}" class="btn btn-primary">
<i class="fas fa-sync-alt me-1"></i> {% trans %}update_manager.progress.refresh_page{% endtrans %}
</a>
{% endif %}
</div>
{# Warning Notice #}
{% if not progress or progress.status == 'running' or progress.status == 'starting' %}
<div class="alert alert-warning mt-4">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>{% trans %}update_manager.progress.warning{% endtrans %}:</strong>
{% if is_downgrade|default(false) %}
{% trans %}update_manager.progress.downgrade_do_not_close{% endtrans %}
{% else %}
{% trans %}update_manager.progress.do_not_close{% endtrans %}
{% endif %}
</div>
{# JavaScript refresh - more reliable than meta refresh #}
<script nonce="{{ csp_nonce('script') }}">
setTimeout(function() {
window.location.reload();
}, 5000);
</script>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,110 @@
{% extends "main_card.html.twig" %}
{% block title %}{{ release.name }} - {% trans %}update_manager.release_notes{% endtrans %}{% endblock %}
{% block card_title %}
<i class="fas fa-file-alt"></i> {{ release.name }}
{% endblock %}
{% block card_content %}
<div class="mb-4">
<a href="{{ path('admin_update_manager') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans %}update_manager.back_to_update_manager{% endtrans %}
</a>
</div>
<div class="row mb-4">
<div class="col-md-6">
<table class="table table-sm">
<tr>
<th style="width: 30%">{% trans %}update_manager.version{% endtrans %}</th>
<td>
<span class="badge bg-primary fs-6">{{ release.version }}</span>
{% if release.prerelease %}
<span class="badge bg-warning text-dark ms-1">{% trans %}update_manager.prerelease{% endtrans %}</span>
{% endif %}
</td>
</tr>
<tr>
<th>{% trans %}update_manager.tag{% endtrans %}</th>
<td><code>{{ release.tag }}</code></td>
</tr>
<tr>
<th>{% trans %}update_manager.released{% endtrans %}</th>
<td>{{ release.published_at|date('Y-m-d H:i') }}</td>
</tr>
<tr>
<th>{% trans %}update_manager.status{% endtrans %}</th>
<td>
{% if release.version == current_version %}
<span class="badge bg-primary">{% trans %}update_manager.current{% endtrans %}</span>
{% elseif release.version > current_version %}
<span class="badge bg-success">{% trans %}update_manager.newer{% endtrans %}</span>
{% else %}
<span class="badge bg-secondary">{% trans %}update_manager.older{% endtrans %}</span>
{% endif %}
</td>
</tr>
</table>
</div>
<div class="col-md-6 text-md-end">
<a href="{{ release.url }}" class="btn btn-primary" target="_blank">
<i class="fab fa-github me-1"></i> {% trans %}update_manager.view_on_github{% endtrans %}
</a>
{% if release.zipball_url %}
<a href="{{ release.zipball_url }}" class="btn btn-outline-secondary">
<i class="fas fa-download me-1"></i> ZIP
</a>
{% endif %}
</div>
</div>
{% if release.assets is not empty %}
<div class="card mb-4">
<div class="card-header">
<i class="fas fa-paperclip me-2"></i>{% trans %}update_manager.download_assets{% endtrans %}
</div>
<div class="card-body">
<ul class="list-unstyled mb-0">
{% for asset in release.assets %}
<li class="mb-2">
<a href="{{ asset.url }}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-download me-1"></i> {{ asset.name }}
</a>
<span class="text-muted ms-2">({{ (asset.size / 1024 / 1024)|number_format(1) }} MB)</span>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<div class="card">
<div class="card-header">
<i class="fas fa-list-ul me-2"></i>{% trans %}update_manager.changelog{% endtrans %}
</div>
<div class="card-body">
{% if release.body %}
<div class="markdown-body">
{{ release.body|markdown_to_html }}
</div>
{% else %}
<p class="text-muted mb-0">{% trans %}update_manager.no_release_notes{% endtrans %}</p>
{% endif %}
</div>
</div>
{% if release.version > current_version %}
<div class="card mt-4 border-success">
<div class="card-header bg-success text-white">
<i class="fas fa-arrow-up me-2"></i>{% trans %}update_manager.update_to_this_version{% endtrans %}
</div>
<div class="card-body">
<p>{% trans %}update_manager.run_command_to_update{% endtrans %}</p>
<div class="bg-dark text-light p-3 rounded">
<code class="text-info">php bin/console partdb:update {{ release.tag }}</code>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -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'];
}

View file

@ -0,0 +1,102 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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]);
}
}

View file

@ -0,0 +1,167 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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 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());
}
}

View file

@ -12826,6 +12826,12 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>System settings</target>
</segment>
</unit>
<unit id="of5wN9M" name="tree.tools.system.update_manager">
<segment state="translated">
<source>tree.tools.system.update_manager</source>
<target>Update Manager</target>
</segment>
</unit>
<unit id="3YsJ4i6" name="settings.tooltip.overrideable_by_env">
<segment state="translated">
<source>settings.tooltip.overrideable_by_env</source>
@ -14262,7 +14268,7 @@ Please note that this system is currently experimental, and the synonyms defined
<unit id="BlR_EQc" name="settings.ips.buerklin.help">
<segment state="translated">
<source>settings.ips.buerklin.help</source>
<target>Buerklin-API access limits:
<target>Buerklin-API access limits:
100 requests/minute per IP address
Buerklin-API Authentication server:
10 requests/minute per IP address</target>
@ -14286,6 +14292,552 @@ Buerklin-API Authentication server:
<target>Transport error while retrieving information from the providers. Check that your server has internet accesss. See server logs for more info.</target>
</segment>
</unit>
<unit id="PExOL_J" name="update_manager.title">
<segment state="translated">
<source>update_manager.title</source>
<target>Update Manager</target>
</segment>
</unit>
<unit id="DkbDuK5" name="update_manager.new">
<segment state="translated">
<source>update_manager.new</source>
<target>New</target>
</segment>
</unit>
<unit id="O3BU_4C" name="update_manager.current_installation">
<segment state="translated">
<source>update_manager.current_installation</source>
<target>Current Installation</target>
</segment>
</unit>
<unit id="McAk_FQ" name="update_manager.version">
<segment state="translated">
<source>update_manager.version</source>
<target>Version</target>
</segment>
</unit>
<unit id="wJj_PaX" name="update_manager.installation_type">
<segment state="translated">
<source>update_manager.installation_type</source>
<target>Installation Type</target>
</segment>
</unit>
<unit id="cJRLvZH" name="update_manager.git_branch">
<segment state="translated">
<source>update_manager.git_branch</source>
<target>Git Branch</target>
</segment>
</unit>
<unit id="w_QspQf" name="update_manager.git_commit">
<segment state="translated">
<source>update_manager.git_commit</source>
<target>Git Commit</target>
</segment>
</unit>
<unit id="55D76Ye" name="update_manager.local_changes">
<segment state="translated">
<source>update_manager.local_changes</source>
<target>Local Changes</target>
</segment>
</unit>
<unit id="MauuHyR" name="update_manager.commits_behind">
<segment state="translated">
<source>update_manager.commits_behind</source>
<target>Commits Behind</target>
</segment>
</unit>
<unit id="G9HzSYg" name="update_manager.auto_update_supported">
<segment state="translated">
<source>update_manager.auto_update_supported</source>
<target>Auto-Update Supported</target>
</segment>
</unit>
<unit id="TFuuQVs" name="update_manager.refresh">
<segment state="translated">
<source>update_manager.refresh</source>
<target>Refresh</target>
</segment>
</unit>
<unit id="WV10ozS" name="update_manager.latest_release">
<segment state="translated">
<source>update_manager.latest_release</source>
<target>Latest Release</target>
</segment>
</unit>
<unit id="pMdT6Ln" name="update_manager.tag">
<segment state="translated">
<source>update_manager.tag</source>
<target>Tag</target>
</segment>
</unit>
<unit id="rxA5Q.H" name="update_manager.released">
<segment state="translated">
<source>update_manager.released</source>
<target>Released</target>
</segment>
</unit>
<unit id="ZkqzT1I" name="update_manager.release_notes">
<segment state="translated">
<source>update_manager.release_notes</source>
<target>Release Notes</target>
</segment>
</unit>
<unit id="VsLmOMA" name="update_manager.view">
<segment state="translated">
<source>update_manager.view</source>
<target>View</target>
</segment>
</unit>
<unit id="KiDikJp" name="update_manager.view_on_github">
<segment state="translated">
<source>update_manager.view_on_github</source>
<target>View on GitHub</target>
</segment>
</unit>
<unit id="Ps4N7pW" name="update_manager.view_release">
<segment state="translated">
<source>update_manager.view_release</source>
<target>View Release</target>
</segment>
</unit>
<unit id="Op0GjdW" name="update_manager.could_not_fetch_releases">
<segment state="translated">
<source>update_manager.could_not_fetch_releases</source>
<target>Could not fetch release information. Check your internet connection.</target>
</segment>
</unit>
<unit id="p2oOHqy" name="update_manager.how_to_update">
<segment state="translated">
<source>update_manager.how_to_update</source>
<target>How to Update</target>
</segment>
</unit>
<unit id="T8wDaa4" name="update_manager.cli_instruction">
<segment state="translated">
<source>update_manager.cli_instruction</source>
<target>To update Part-DB, run one of the following commands in your terminal:</target>
</segment>
</unit>
<unit id="anX9IHz" name="update_manager.check_for_updates">
<segment state="translated">
<source>update_manager.check_for_updates</source>
<target>Check for updates</target>
</segment>
</unit>
<unit id="jQSkEnc" name="update_manager.update_to_latest">
<segment state="translated">
<source>update_manager.update_to_latest</source>
<target>Update to latest version</target>
</segment>
</unit>
<unit id="BLqqNYf" name="update_manager.update_to_specific">
<segment state="translated">
<source>update_manager.update_to_specific</source>
<target>Update to specific version</target>
</segment>
</unit>
<unit id="sepynnN" name="update_manager.cli_recommendation">
<segment state="translated">
<source>update_manager.cli_recommendation</source>
<target>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.</target>
</segment>
</unit>
<unit id="cTzUzRc" name="update_manager.up_to_date">
<segment state="translated">
<source>update_manager.up_to_date</source>
<target>Up to date</target>
</segment>
</unit>
<unit id="4twG2aT" name="update_manager.newer">
<segment state="translated">
<source>update_manager.newer</source>
<target>Newer</target>
</segment>
</unit>
<unit id="x3_aj6." name="update_manager.current">
<segment state="translated">
<source>update_manager.current</source>
<target>Current</target>
</segment>
</unit>
<unit id="JmMErjH" name="update_manager.older">
<segment state="translated">
<source>update_manager.older</source>
<target>Older</target>
</segment>
</unit>
<unit id="n9bdBil" name="update_manager.prerelease">
<segment state="translated">
<source>update_manager.prerelease</source>
<target>Pre-release</target>
</segment>
</unit>
<unit id="JNSumag" name="update_manager.status">
<segment state="translated">
<source>update_manager.status</source>
<target>Status</target>
</segment>
</unit>
<unit id="CyrTWmk" name="update_manager.available_versions">
<segment state="translated">
<source>update_manager.available_versions</source>
<target>Available Versions</target>
</segment>
</unit>
<unit id="dNDITfe" name="update_manager.no_releases_found">
<segment state="translated">
<source>update_manager.no_releases_found</source>
<target>No releases found</target>
</segment>
</unit>
<unit id="DYpFv6Y" name="update_manager.view_release_notes">
<segment state="translated">
<source>update_manager.view_release_notes</source>
<target>View Release Notes</target>
</segment>
</unit>
<unit id="8OQbJJF" name="update_manager.update_logs">
<segment state="translated">
<source>update_manager.update_logs</source>
<target>Update Logs</target>
</segment>
</unit>
<unit id="EQ.7NKA" name="update_manager.backups">
<segment state="translated">
<source>update_manager.backups</source>
<target>Backups</target>
</segment>
</unit>
<unit id="RzACl7b" name="update_manager.date">
<segment state="translated">
<source>update_manager.date</source>
<target>Date</target>
</segment>
</unit>
<unit id="qrXWWID" name="update_manager.log_file">
<segment state="translated">
<source>update_manager.log_file</source>
<target>Log File</target>
</segment>
</unit>
<unit id="RqecL.0" name="update_manager.no_logs_found">
<segment state="translated">
<source>update_manager.no_logs_found</source>
<target>No update logs found</target>
</segment>
</unit>
<unit id="kBsDajR" name="update_manager.file">
<segment state="translated">
<source>update_manager.file</source>
<target>File</target>
</segment>
</unit>
<unit id="iyqnH4y" name="update_manager.size">
<segment state="translated">
<source>update_manager.size</source>
<target>Size</target>
</segment>
</unit>
<unit id="zrVIiyj" name="update_manager.no_backups_found">
<segment state="translated">
<source>update_manager.no_backups_found</source>
<target>No backups found</target>
</segment>
</unit>
<unit id="hxz8Fme" name="update_manager.validation_issues">
<segment state="translated">
<source>update_manager.validation_issues</source>
<target>Validation Issues</target>
</segment>
</unit>
<unit id="_79O_U0" name="update_manager.maintenance_mode_active">
<segment state="translated">
<source>update_manager.maintenance_mode_active</source>
<target>Maintenance mode is active</target>
</segment>
</unit>
<unit id="A1MNL8F" name="update_manager.update_in_progress">
<segment state="translated">
<source>update_manager.update_in_progress</source>
<target>An update is currently in progress</target>
</segment>
</unit>
<unit id="NQDT5rh" name="update_manager.started_at">
<segment state="translated">
<source>update_manager.started_at</source>
<target>Started at</target>
</segment>
</unit>
<unit id="Y2w3Oal" name="update_manager.new_version_available.message">
<segment state="translated">
<source>update_manager.new_version_available.message</source>
<target>Part-DB version %version% is now available! Consider updating to get the latest features and security fixes.</target>
</segment>
</unit>
<unit id="vp8Fp8e" name="update_manager.changelog">
<segment state="translated">
<source>update_manager.changelog</source>
<target>Changelog</target>
</segment>
</unit>
<unit id="wYtPY8_" name="update_manager.no_release_notes">
<segment state="translated">
<source>update_manager.no_release_notes</source>
<target>No release notes available for this version.</target>
</segment>
</unit>
<unit id="0amNk.A" name="update_manager.back_to_update_manager">
<segment state="translated">
<source>update_manager.back_to_update_manager</source>
<target>Back to Update Manager</target>
</segment>
</unit>
<unit id="3RaBRRh" name="update_manager.download_assets">
<segment state="translated">
<source>update_manager.download_assets</source>
<target>Download</target>
</segment>
</unit>
<unit id="yw.Bqwa" name="update_manager.update_to_this_version">
<segment state="translated">
<source>update_manager.update_to_this_version</source>
<target>Update to this Version</target>
</segment>
</unit>
<unit id="458.UTU" name="update_manager.run_command_to_update">
<segment state="translated">
<source>update_manager.run_command_to_update</source>
<target>Run the following command in your terminal to update to this version:</target>
</segment>
</unit>
<unit id="1iYIZ6C" name="update_manager.log_viewer">
<segment state="translated">
<source>update_manager.log_viewer</source>
<target>Log Viewer</target>
</segment>
</unit>
<unit id="AXavng5" name="update_manager.update_log">
<segment state="translated">
<source>update_manager.update_log</source>
<target>Update Log</target>
</segment>
</unit>
<unit id=".QzwEpo" name="update_manager.bytes">
<segment state="translated">
<source>update_manager.bytes</source>
<target>bytes</target>
</segment>
</unit>
<unit id="Gt.91s_" name="perm.system.manage_updates">
<segment state="translated">
<source>perm.system.manage_updates</source>
<target>Manage Part-DB updates</target>
</segment>
</unit>
<unit id="Mw2sya4" name="update_manager.create_backup">
<segment state="translated">
<source>update_manager.create_backup</source>
<target>Create backup before updating (recommended)</target>
</segment>
</unit>
<unit id="fWGZSZ1" name="update_manager.confirm_update">
<segment state="translated">
<source>update_manager.confirm_update</source>
<target>Are you sure you want to update Part-DB? A backup will be created before the update.</target>
</segment>
</unit>
<unit id="Fashdp." name="update_manager.already_up_to_date">
<segment state="translated">
<source>update_manager.already_up_to_date</source>
<target>You are running the latest version of Part-DB.</target>
</segment>
</unit>
<unit id="JPZ9w0l" name="update_manager.progress.title">
<segment state="translated">
<source>update_manager.progress.title</source>
<target>Update Progress</target>
</segment>
</unit>
<unit id="7oAKwee" name="update_manager.progress.updating">
<segment state="translated">
<source>update_manager.progress.updating</source>
<target>Updating Part-DB...</target>
</segment>
</unit>
<unit id="ffxDMB4" name="update_manager.progress.completed">
<segment state="translated">
<source>update_manager.progress.completed</source>
<target>Update Completed!</target>
</segment>
</unit>
<unit id="ZKFaIiJ" name="update_manager.progress.failed">
<segment state="translated">
<source>update_manager.progress.failed</source>
<target>Update Failed</target>
</segment>
</unit>
<unit id="bSdCWOK" name="update_manager.progress.initializing">
<segment state="translated">
<source>update_manager.progress.initializing</source>
<target>Initializing...</target>
</segment>
</unit>
<unit id="1kwqhDj" name="update_manager.progress.updating_to">
<segment state="translated">
<source>update_manager.progress.updating_to</source>
<target>Updating to version %version%</target>
</segment>
</unit>
<unit id="9iiGqBW" name="update_manager.progress.downgrading_to">
<segment state="translated">
<source>update_manager.progress.downgrading_to</source>
<target>Downgrading to version %version%</target>
</segment>
</unit>
<unit id="53NE8hP" name="update_manager.progress.error">
<segment state="translated">
<source>update_manager.progress.error</source>
<target>Error</target>
</segment>
</unit>
<unit id="30Cg6Gj" name="update_manager.progress.success_message">
<segment state="translated">
<source>update_manager.progress.success_message</source>
<target>Part-DB has been successfully updated! You may need to refresh the page to see the new version.</target>
</segment>
</unit>
<unit id="qnpVyIP" name="update_manager.progress.steps">
<segment state="translated">
<source>update_manager.progress.steps</source>
<target>Update Steps</target>
</segment>
</unit>
<unit id="048it9m" name="update_manager.progress.waiting">
<segment state="translated">
<source>update_manager.progress.waiting</source>
<target>Waiting for update to start...</target>
</segment>
</unit>
<unit id="uydk07m" name="update_manager.progress.back">
<segment state="translated">
<source>update_manager.progress.back</source>
<target>Back to Update Manager</target>
</segment>
</unit>
<unit id="egnoPy_" name="update_manager.progress.refresh_page">
<segment state="translated">
<source>update_manager.progress.refresh_page</source>
<target>Refresh Page</target>
</segment>
</unit>
<unit id="q9vs_v7" name="update_manager.progress.warning">
<segment state="translated">
<source>update_manager.progress.warning</source>
<target>Important</target>
</segment>
</unit>
<unit id="b.ubGIM" name="update_manager.progress.do_not_close">
<segment state="translated">
<source>update_manager.progress.do_not_close</source>
<target>Please do not close this page or navigate away while the update is in progress. The update will continue even if you close this page, but you won't be able to monitor the progress.</target>
</segment>
</unit>
<unit id="SQxsQbQ" name="update_manager.progress.auto_refresh">
<segment state="translated">
<source>update_manager.progress.auto_refresh</source>
<target>This page will automatically refresh every 2 seconds to show progress.</target>
</segment>
</unit>
<unit id="aSHDhOi" name="update_manager.progress.downgrade_title">
<segment state="translated">
<source>update_manager.progress.downgrade_title</source>
<target>Downgrade Progress</target>
</segment>
</unit>
<unit id="XYR1vvR" name="update_manager.progress.downgrade_completed">
<segment state="translated">
<source>update_manager.progress.downgrade_completed</source>
<target>Downgrade Completed!</target>
</segment>
</unit>
<unit id="OaKRuqb" name="update_manager.progress.downgrade_failed">
<segment state="translated">
<source>update_manager.progress.downgrade_failed</source>
<target>Downgrade Failed</target>
</segment>
</unit>
<unit id="bDwkcfw" name="update_manager.progress.downgrade_success_message">
<segment state="translated">
<source>update_manager.progress.downgrade_success_message</source>
<target>Part-DB has been successfully downgraded! You may need to refresh the page to see the new version.</target>
</segment>
</unit>
<unit id="U_wIKH7" name="update_manager.progress.downgrade_steps">
<segment state="translated">
<source>update_manager.progress.downgrade_steps</source>
<target>Downgrade Steps</target>
</segment>
</unit>
<unit id="quoDXGW" name="update_manager.progress.downgrade_do_not_close">
<segment state="translated">
<source>update_manager.progress.downgrade_do_not_close</source>
<target>Please do not close this page or navigate away while the downgrade is in progress. The downgrade will continue even if you close this page, but you won't be able to monitor the progress.</target>
</segment>
</unit>
<unit id="k056kqg" name="update_manager.confirm_downgrade">
<segment state="translated">
<source>update_manager.confirm_downgrade</source>
<target>Are you sure you want to downgrade Part-DB? This will revert to an older version. A backup will be created before the downgrade.</target>
</segment>
</unit>
<unit id="FqMFu60" name="update_manager.downgrade_removes_update_manager">
<segment state="translated">
<source>update_manager.downgrade_removes_update_manager</source>
<target>WARNING: This version does not include the Update Manager. After downgrading, you will need to update manually using the command line (git checkout, composer install, etc.).</target>
</segment>
</unit>
<unit id="gJ1zayM" name="update_manager.restore_backup">
<segment state="translated">
<source>update_manager.restore_backup</source>
<target>Restore backup</target>
</segment>
</unit>
<unit id="rzkOf7b" name="update_manager.restore_confirm_title">
<segment state="translated">
<source>update_manager.restore_confirm_title</source>
<target>Restore from Backup</target>
</segment>
</unit>
<unit id="sKf8kah" name="update_manager.restore_confirm_message">
<segment state="translated">
<source>update_manager.restore_confirm_message</source>
<target>Are you sure you want to restore your database from this backup?</target>
</segment>
</unit>
<unit id="iCc3vNw" name="update_manager.restore_confirm_warning">
<segment state="translated">
<source>update_manager.restore_confirm_warning</source>
<target>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.</target>
</segment>
</unit>
<unit id="Tsaqpw6" name="update_manager.web_updates_disabled">
<segment state="translated">
<source>update_manager.web_updates_disabled</source>
<target>Web-based updates are disabled</target>
</segment>
</unit>
<unit id="ievNCa2" name="update_manager.web_updates_disabled_hint">
<segment state="translated">
<source>update_manager.web_updates_disabled_hint</source>
<target>Web-based updates have been disabled by the server administrator. Please use the CLI command "php bin/console partdb:update" to perform updates.</target>
</segment>
</unit>
<unit id="Gq.JxJC" name="update_manager.backup_restore_disabled">
<segment state="translated">
<source>update_manager.backup_restore_disabled</source>
<target>Backup restore is disabled by server configuration.</target>
</segment>
</unit>
<unit id="kHKChQB" name="settings.ips.conrad">
<segment>
<source>settings.ips.conrad</source>
@ -14358,5 +14910,23 @@ Buerklin-API Authentication server:
<target>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.</target>
</segment>
</unit>
<unit id="SWd8mI9" name="update_manager.cant_auto_update">
<segment>
<source>update_manager.cant_auto_update</source>
<target>Cannot update automatically from WebUI</target>
</segment>
</unit>
<unit id="XquvWL5" name="update_manager.switch_to">
<segment>
<source>update_manager.switch_to</source>
<target>Switch to</target>
</segment>
</unit>
<unit id="oKMBb9S" name="update_manager.update_to">
<segment>
<source>update_manager.update_to</source>
<target>Update to</target>
</segment>
</unit>
</file>
</xliff>

138
yarn.lock
View file

@ -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: