Compare commits

..

17 Commits

Author SHA1 Message Date
Jan Böhmer
37b98adc6e Bumped version to 2.9.0 2026-03-07 22:57:54 +01:00
Jan Böhmer
4f12fd7390 New Crowdin updates (#1294)
* New translations messages.en.xlf (English)

* New translations messages.en.xlf (English)

* New translations validators.en.xlf (German)

* New translations messages.en.xlf (German)
2026-03-07 22:51:02 +01:00
Jan Böhmer
13b98cc0b1 Fixed tests 2026-03-07 22:47:05 +01:00
Jan Böhmer
7f8f5990a7 Fixed phpstan issues 2026-03-07 22:30:39 +01:00
Jan Böhmer
bcbbb1ecb9 Add a flash notice when automatically creating a part lot from scan 2026-03-07 22:01:50 +01:00
Jan Böhmer
8727d83097 Increase possible length of the vendor barcode column in part lots
This allows us to store full 2D barcodes content there
2026-03-07 21:54:46 +01:00
Jan Böhmer
70919d953a Allow to pass infos from barcodes to creation dialog 2026-03-07 21:48:27 +01:00
Jan Böhmer
a722608ae8 Clear input after option selection in tomselect fields
Fixes issue #1264
2026-03-07 21:22:29 +01:00
Jan Böhmer
12a760d27e Correctly denormalize parent-child relationships in import, when only children not parent fields are given
This fixes issue #1272
2026-03-07 21:08:32 +01:00
Jan Böhmer
b8d1414403 Handle Barcode placeholders before anything else to avoid wrong delegation
Fixes issue #1268
2026-03-07 19:56:14 +01:00
Jan Böhmer
463d7b89f6 Added part description as property to KiCad response, to show it also in Kicad 9.0.5 and 9.06
Fixes #1291
2026-03-07 19:45:09 +01:00
Marc
6e4d252617 Show ManufacturingStatus in BOM (#1289) 2026-03-07 19:35:08 +01:00
Niklas
3ed27f6c0f /api/part_lots: add user_barcode filter (#1280)
* /api/part_lots: add user_barcode filter

* support LIKE filtering for part lot user_barcode
2026-03-07 19:31:47 +01:00
Sebastian Almberg
0d58262e19 Add manual backup creation and delete buttons to Update Manager (#1255)
* Add manual backup creation and delete buttons to Update Manager

- Add "Create Backup" button in the backups tab for on-demand backups
- Add delete buttons (trash icons) for update logs and backups
- New controller routes with CSRF protection and permission checks
- Use data-turbo-confirm for CSP-safe confirmation dialogs
- Add deleteLog() method to UpdateExecutor with filename validation

* Add Docker backup support: download button, SQLite restore fix, decouple from auto-update

- Decouple backup creation/restore UI from can_auto_update so Docker
  and other non-git installations can use backup features
- Add backup download endpoint for saving backups externally
- Fix SQLite restore to use configured DATABASE_URL path instead of
  hardcoded var/app.db (affects Docker and custom SQLite paths)
- Show Docker-specific warning about var/backups/ not being persisted
- Pass is_docker flag to template via InstallationTypeDetector

* Add tests for backup/update manager improvements

- Controller tests: auth, CSRF validation, 404 for missing backups, restore disabled check
- UpdateExecutor: deleteLog validation, non-existent file, successful deletion
- BackupManager: deleteBackup validation for missing/non-zip files

* Fix test failures: add locale prefix to URLs, correct log directory path

* Fix auth test: expect 401 instead of redirect for HTTP Basic auth

* Improve test coverage for update manager controller

Add happy-path tests for backup creation, deletion, download,
and log deletion with valid CSRF tokens. Also test the locked
state blocking backup creation.

* Fix CSRF tests: initialize session before getting tokens

* Fix CSRF tests: extract tokens from rendered page HTML

* Harden backup security: password confirmation, CSRF, env toggle

Address security review feedback from jbtronics:

- Add IS_AUTHENTICATED_FULLY to all sensitive endpoints (create/delete
  backup, delete log, download backup, start update, restore)
- Change backup download from GET to POST with CSRF token
- Require password confirmation before downloading backups (backups
  contain sensitive data like password hashes and secrets)
- Add DISABLE_BACKUP_DOWNLOAD env var (default: disabled) to control
  whether backup downloads are allowed
- Add password confirmation modal with security warning in template
- Add comprehensive tests: auth checks, env var blocking, POST-only
  enforcement, status/progress endpoint auth

* Fix download modal: use per-backup modals for CSP/Turbo compatibility

- Replace shared modal + inline JS with per-backup modals that have
  filename pre-set in hidden fields (no JavaScript needed)
- Add data-turbo="false" to download forms for native browser handling
- Add data-bs-dismiss="modal" to submit button to auto-close modal
- Add hidden username field for Chrome accessibility best practice
- Fix test: GET on POST-only route returns 404 not 405

* Fixed translation keys

* Fixed text justification in download modal

* Hardenened security of deleteLogEndpoint

* Show whether backup, restores and updates are allowed or disabled by sysadmin on update manager

* Added documentation for update manager related env variables

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-03-07 19:31:00 +01:00
Sebastian Almberg
db8881621c Add OPcache reset step to update and restore processes (#1288)
After cache warmup, create a temporary PHP script in the public
directory and invoke it via HTTP to reset OPcache in the PHP-FPM
context. This prevents stale bytecode from causing 500 errors when
the progress page refreshes after code has been updated.

The reset is also performed after rollback and during restore.
Uses a random token in the filename for security, and the script
self-deletes after execution with a cleanup in the finally block.
2026-03-07 18:10:36 +01:00
Jan Böhmer
ceda91488c Updated dependencies 2026-03-07 16:20:42 +01:00
Copilot
e84bae2807 Make form layout better at wide screens & Make horizontal form column layout configurable via global Twig variables (#1293)
* Initial plan

* Make form column layout configurable with global Twig variables

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Rename form column Twig globals to shorter names: label_col, input_col, offset_col

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Fixed remaining places where offsets where used

* Fixed margin of delete button on admin forms

* Rename Twig globals: col_label, col_input, offset_label

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Added documentation to our twig class variables

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-03-07 16:14:58 +01:00
40 changed files with 1878 additions and 516 deletions

5
.env
View File

@@ -71,6 +71,11 @@ DISABLE_WEB_UPDATES=1
# Restoring backups is a destructive operation that could overwrite your database.
DISABLE_BACKUP_RESTORE=1
# Disable backup download from the Update Manager UI (0=enabled, 1=disabled).
# Backups contain sensitive data including password hashes and secrets.
# When enabled, users must confirm their password before downloading.
DISABLE_BACKUP_DOWNLOAD=1
###################################################################################
# SAML Single sign on-settings
###################################################################################

View File

@@ -1 +1 @@
2.8.1
2.9.0

View File

@@ -45,6 +45,7 @@ export default class extends Controller {
maxItems: 1,
createOnBlur: true,
selectOnTab: true,
clearAfterSelect: true,
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
dropdownParent: dropdownParent,

View File

@@ -23,6 +23,8 @@ export default class extends Controller {
valueField: "id",
labelField: "name",
dropdownParent: dropdownParent,
selectOnTab: true,
clearAfterSelect: true,
preload: "focus",
render: {
item: (data, escape) => {

View File

@@ -49,6 +49,7 @@ export default class extends Controller {
selectOnTab: true,
maxOptions: null,
dropdownParent: dropdownParent,
clearAfterSelect: true,
render: {
item: this.renderItem.bind(this),

View File

@@ -35,6 +35,8 @@ export default class extends Controller {
maxItems: 1000,
allowEmptyOption: true,
dropdownParent: dropdownParent,
selectOnTab: true,
clearAfterSelect: true,
plugins: ['remove_button'],
});
}

View File

@@ -56,6 +56,7 @@ export default class extends Controller {
searchField: 'text',
orderField: 'text',
dropdownParent: dropdownParent,
clearAfterSelect: true,
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',

View File

@@ -58,6 +58,7 @@ export default class extends Controller {
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
splitOn: null,
dropdownParent: dropdownParent,
clearAfterSelect: true,
searchField: [
{field: "text", weight : 2},

View File

@@ -49,6 +49,7 @@ export default class extends Controller {
createOnBlur: true,
create: true,
dropdownParent: dropdownParent,
clearAfterSelect: true,
};
if(this.element.dataset.autocomplete) {

View File

@@ -75,6 +75,7 @@ export default class extends Controller
searchField: "name",
//labelField: "name",
valueField: "name",
clearAfterSelect: true,
onItemAdd: this.onItemAdd.bind(this),
render: {
option: (data, escape) => {
@@ -136,4 +137,4 @@ export default class extends Controller
//Destroy the TomSelect instance
this._tomSelect.destroy();
}
}
}

View File

@@ -177,6 +177,11 @@
"allow-contrib": false,
"require": "7.4.*",
"docker": true
},
"phpstan/extension-installer": {
"ignore" : [
"ekino/phpstan-banned-code"
]
}
}
}

382
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -144,6 +144,18 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
* `ALLOW_EMAIL_PW_RESET`: Set this value to true, if you want to allow users to reset their password via an email
notification. You have to configure the mail provider first before via the MAILER_DSN setting.
### Update manager settings
* `DISABLE_WEB_UPDATES` (default `1`): Set this to 0 to enable web-based updates. When enabled, you can perform updates
via the web interface in the update manager. This is disabled by default for security reasons, as it can be a risk if
not used carefully. You can still use the CLI commands to perform updates, even when web updates are disabled.
* `DISABLE_BACKUP_RESTORE` (default `1`): Set this to 0 to enable backup restore via the web interface. When enabled, you can
restore backups via the web interface in the update manager. This is disabled by default for security reasons, as it can
be a risk if not used carefully. You can still use the CLI commands to perform backup restores, even when web-based
backup restore is disabled.
* `DISABLE_BACKUP_DOWNLOAD` (default `1`): Set this to 0 to enable backup download via the web interface. When enabled, you can download backups via the web interface
in the update manager. This is disabled by default for security reasons, as it can be a risk if not used carefully, as
the downloads contain sensitive data like password hashes or secrets.
### Table related settings
* `TABLE_DEFAULT_PAGE_SIZE`: The default page size for tables. This is the number of rows which are shown per page. Set

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260307204859 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Increase the length of the vendor_barcode field in part_lots to 1000 characters and update the index accordingly';
}
public function mySQLUp(Schema $schema): void
{
$this->addSql('ALTER TABLE part_lots DROP INDEX part_lots_idx_barcode, ADD INDEX part_lots_idx_barcode (vendor_barcode(100))');
$this->addSql('ALTER TABLE part_lots CHANGE vendor_barcode vendor_barcode LONGTEXT DEFAULT NULL');
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE part_lots DROP INDEX part_lots_idx_barcode, ADD INDEX part_lots_idx_barcode (vendor_barcode)');
$this->addSql('ALTER TABLE part_lots CHANGE vendor_barcode vendor_barcode VARCHAR(255) DEFAULT NULL');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at FROM part_lots');
$this->addSql('DROP TABLE part_lots');
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, vendor_barcode CLOB DEFAULT NULL, last_stocktake_at DATETIME DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES storelocations (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES parts (id) ON UPDATE NO ACTION ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO part_lots (id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at) SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at FROM __temp__part_lots');
$this->addSql('DROP TABLE __temp__part_lots');
$this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)');
$this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)');
$this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)');
$this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)');
$this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)');
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, last_modified, datetime_added, id_store_location, id_part, id_owner FROM part_lots');
$this->addSql('DROP TABLE part_lots');
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, vendor_barcode VARCHAR(255) DEFAULT NULL, last_stocktake_at DATETIME DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES "storelocations" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES "users" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO part_lots (id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, last_modified, datetime_added, id_store_location, id_part, id_owner) SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, last_modified, datetime_added, id_store_location, id_part, id_owner FROM __temp__part_lots');
$this->addSql('DROP TABLE __temp__part_lots');
$this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)');
$this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)');
$this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)');
$this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)');
$this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)');
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
}
public function postgreSQLUp(Schema $schema): void
{
$this->addSql('DROP INDEX part_lots_idx_barcode');
$this->addSql('ALTER TABLE part_lots ALTER vendor_barcode TYPE TEXT');
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
}
public function postgreSQLDown(Schema $schema): void
{
$this->addSql('DROP INDEX part_lots_idx_barcode');
$this->addSql('ALTER TABLE part_lots ALTER vendor_barcode TYPE VARCHAR(255)');
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
}
}

86
phpstan.banned_code.neon Normal file
View File

@@ -0,0 +1,86 @@
# Manually configure ekino/phpstan-banned-code to detect usage of echo, eval, die/exit, print, shell execution and a set of functions that should not be used in production code.
parametersSchema:
banned_code: structure([
nodes: listOf(structure([
type: string()
functions: schema(listOf(string()), nullable())
]))
use_from_tests: bool()
non_ignorable: bool()
])
parameters:
banned_code:
nodes:
# enable detection of echo
-
type: Stmt_Echo
functions: null
# enable detection of eval
-
type: Expr_Eval
functions: null
# enable detection of die/exit
-
type: Expr_Exit
functions: null
# enable detection of a set of functions
-
type: Expr_FuncCall
functions:
- dd
- debug_backtrace
- dump
- exec
- passthru
- phpinfo
- print_r
- proc_open
- shell_exec
- system
- var_dump
# enable detection of print statements
-
type: Expr_Print
functions: null
# enable detection of shell execution by backticks
-
type: Expr_ShellExec
functions: null
# enable detection of empty()
#-
# type: Expr_Empty
# functions: null
# enable detection of `use Tests\Foo\Bar` in a non-test file
use_from_tests: true
# when true, errors cannot be excluded
non_ignorable: false
services:
-
class: Ekino\PHPStanBannedCode\Rules\BannedNodesRule
tags:
- phpstan.rules.rule
arguments:
- '%banned_code.nodes%'
-
class: Ekino\PHPStanBannedCode\Rules\BannedUseTestRule
tags:
- phpstan.rules.rule
arguments:
- '%banned_code.use_from_tests%'
-
class: Ekino\PHPStanBannedCode\Rules\BannedNodesErrorBuilder
arguments:
- '%banned_code.non_ignorable%'

View File

@@ -1,3 +1,6 @@
includes:
- phpstan.banned_code.neon
parameters:
level: 5
@@ -6,9 +9,6 @@ parameters:
- src
# - tests
banned_code:
non_ignorable: false # Allow to ignore some banned code
excludePaths:
- src/DataTables/Adapter/*
- src/Configuration/*

View File

@@ -290,6 +290,23 @@ final class PartController extends AbstractController
$this->addFlash('warning', t("part.create_from_info_provider.no_category_yet"));
}
$lotAmount = $request->query->get('lotAmount');
$lotName = $request->query->get('lotName');
$lotUserBarcode = $request->query->get('lotUserBarcode');
if ($lotAmount !== null || $lotName !== null || $lotUserBarcode !== null) {
$partLot = new PartLot();
$partLot->setAmount($lotAmount !== null ? (float)$lotAmount : 0);
$partLot->setDescription($lotName !== null ? (string)$lotName : '');
$partLot->setUserBarcode($lotUserBarcode !== null ? (string)$lotUserBarcode : '');
$new_part->addPartLot($partLot);
$this->addFlash('notice', t('part.create_from_info_provider.lot_filled_from_barcode'));
}
return $this->renderPartForm('new', $request, $new_part, [
'info_provider_dto' => $dto,
]);

View File

@@ -23,16 +23,21 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\UserSystem\User;
use App\Services\System\BackupManager;
use App\Services\System\InstallationTypeDetector;
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\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
@@ -49,10 +54,14 @@ class UpdateManagerController extends AbstractController
private readonly UpdateExecutor $updateExecutor,
private readonly VersionManagerInterface $versionManager,
private readonly BackupManager $backupManager,
private readonly InstallationTypeDetector $installationTypeDetector,
private readonly UserPasswordHasherInterface $passwordHasher,
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
private readonly bool $webUpdatesDisabled = false,
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
private readonly bool $backupRestoreDisabled = false,
#[Autowire(env: 'bool:DISABLE_BACKUP_DOWNLOAD')]
private readonly bool $backupDownloadDisabled = false,
) {
}
@@ -76,6 +85,16 @@ class UpdateManagerController extends AbstractController
}
}
/**
* Check if backup download is disabled and throw exception if so.
*/
private function denyIfBackupDownloadDisabled(): void
{
if ($this->backupDownloadDisabled) {
throw new AccessDeniedHttpException('Backup download is disabled by server configuration.');
}
}
/**
* Main update manager page.
*/
@@ -101,6 +120,8 @@ class UpdateManagerController extends AbstractController
'backups' => $this->backupManager->getBackups(),
'web_updates_disabled' => $this->webUpdatesDisabled,
'backup_restore_disabled' => $this->backupRestoreDisabled,
'backup_download_disabled' => $this->backupDownloadDisabled,
'is_docker' => $this->installationTypeDetector->isDocker(),
]);
}
@@ -206,6 +227,7 @@ class UpdateManagerController extends AbstractController
#[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])]
public function startUpdate(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfWebUpdatesDisabled();
@@ -314,12 +336,126 @@ class UpdateManagerController extends AbstractController
return $this->json($details);
}
/**
* Create a manual backup.
*/
#[Route('/backup', name: 'admin_update_manager_backup', methods: ['POST'])]
public function createBackup(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
if (!$this->isCsrfTokenValid('update_manager_backup', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('admin_update_manager');
}
if ($this->updateExecutor->isLocked()) {
$this->addFlash('error', 'Cannot create backup while an update is in progress.');
return $this->redirectToRoute('admin_update_manager');
}
try {
$this->backupManager->createBackup(null, 'manual');
$this->addFlash('success', 'update_manager.backup.created');
} catch (\Exception $e) {
$this->addFlash('error', 'Backup failed: ' . $e->getMessage());
}
return $this->redirectToRoute('admin_update_manager');
}
/**
* Delete a backup file.
*/
#[Route('/backup/delete', name: 'admin_update_manager_backup_delete', methods: ['POST'])]
public function deleteBackup(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('admin_update_manager');
}
$filename = $request->request->get('filename');
if ($filename && $this->backupManager->deleteBackup($filename)) {
$this->addFlash('success', 'update_manager.backup.deleted');
} else {
$this->addFlash('error', 'update_manager.backup.delete_error');
}
return $this->redirectToRoute('admin_update_manager');
}
/**
* Delete an update log file.
*/
#[Route('/log/delete', name: 'admin_update_manager_log_delete', methods: ['POST'])]
public function deleteLog(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('admin_update_manager');
}
$filename = $request->request->get('filename');
if ($filename && $this->updateExecutor->deleteLog($filename)) {
$this->addFlash('success', 'update_manager.log.deleted');
} else {
$this->addFlash('error', 'update_manager.log.delete_error');
}
return $this->redirectToRoute('admin_update_manager');
}
/**
* Download a backup file.
* Requires password confirmation as backups contain sensitive data (password hashes, secrets, etc.).
*/
#[Route('/backup/download', name: 'admin_update_manager_backup_download', methods: ['POST'])]
public function downloadBackup(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfBackupDownloadDisabled();
if (!$this->isCsrfTokenValid('update_manager_download', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('admin_update_manager');
}
// Verify password
$password = $request->request->get('password', '');
$user = $this->getUser();
if (!$user instanceof User || !$this->passwordHasher->isPasswordValid($user, $password)) {
$this->addFlash('error', 'update_manager.backup.download.invalid_password');
return $this->redirectToRoute('admin_update_manager');
}
$filename = $request->request->get('filename', '');
$details = $this->backupManager->getBackupDetails($filename);
if (!$details) {
throw $this->createNotFoundException('Backup not found');
}
$response = new BinaryFileResponse($details['path']);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $details['file']);
return $response;
}
/**
* Restore from a backup.
*/
#[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
public function restore(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfBackupRestoreDisabled();

View File

@@ -23,11 +23,13 @@ declare(strict_types=1);
namespace App\DataTables;
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\EnumColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Part;
use App\Entity\Parts\ManufacturingStatus;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator;
@@ -145,6 +147,19 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
'orderField' => 'NATSORT(manufacturer.name)',
])
->add('manufacturing_status', EnumColumn::class, [
'label' => $this->translator->trans('part.table.manufacturingStatus'),
'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(),
'class' => ManufacturingStatus::class,
'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string {
if ($status === null) {
return '';
}
return $this->translator->trans($status->toTranslationKey());
},
])
->add('mountnames', TextColumn::class, [
'label' => 'project.bom.mountnames',
'render' => function ($value, ProjectBOMEntry $context) {

View File

@@ -66,7 +66,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Table(name: 'part_lots')]
#[ORM\Index(columns: ['instock_unknown', 'expiration_date', 'id_part'], name: 'part_lots_idx_instock_un_expiration_id_part')]
#[ORM\Index(columns: ['needs_refill'], name: 'part_lots_idx_needs_refill')]
#[ORM\Index(columns: ['vendor_barcode'], name: 'part_lots_idx_barcode')]
#[ORM\Index(name: 'part_lots_idx_barcode', columns: ['vendor_barcode'], options: ['lengths' => [100]])]
#[ValidPartLot]
#[UniqueEntity(['user_barcode'], message: 'validator.part_lot.vendor_barcode_must_be_unique')]
#[ApiResource(
@@ -81,7 +81,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
denormalizationContext: ['groups' => ['part_lot:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["description", "comment"])]
#[ApiFilter(LikeFilter::class, properties: ["description", "comment", "user_barcode"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
#[ApiFilter(BooleanFilter::class, properties: ['instock_unknown', 'needs_refill'])]
#[ApiFilter(RangeFilter::class, properties: ['amount'])]
@@ -166,9 +166,8 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
/**
* @var string|null The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor)
*/
#[ORM\Column(name: "vendor_barcode", type: Types::STRING, nullable: true)]
#[ORM\Column(name: "vendor_barcode", type: Types::TEXT, nullable: true)]
#[Groups(['part_lot:read', 'part_lot:write'])]
#[Length(max: 255)]
protected ?string $user_barcode = null;
/**

View File

@@ -42,6 +42,8 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
private const ALREADY_CALLED = 'STRUCTURAL_DENORMALIZER_ALREADY_CALLED';
private const PARENT_ELEMENT = 'STRUCTURAL_DENORMALIZER_PARENT_ELEMENT';
private array $object_cache = [];
public function __construct(
@@ -89,37 +91,59 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz
$context[self::ALREADY_CALLED][] = $data;
//In the first step, denormalize without children
$context_without_children = $context;
$context_without_children['groups'] = array_filter(
$context_without_children['groups'] ?? [],
static fn($group) => $group !== 'include_children',
);
//Also unset any parent element, to avoid infinite loops. We will set the parent element in the next step, when we denormalize the children
unset($context_without_children[self::PARENT_ELEMENT]);
/** @var AbstractStructuralDBElement $entity */
$entity = $this->denormalizer->denormalize($data, $type, $format, $context_without_children);
/** @var AbstractStructuralDBElement $deserialized_entity */
$deserialized_entity = $this->denormalizer->denormalize($data, $type, $format, $context);
//Assign the parent element to the denormalized entity, so it can be used in the denormalization of the children (e.g. for path generation)
if (isset($context[self::PARENT_ELEMENT]) && $context[self::PARENT_ELEMENT] instanceof $entity && $entity->getID() === null) {
$entity->setParent($context[self::PARENT_ELEMENT]);
}
//Check if we already have the entity in the database (via path)
/** @var StructuralDBElementRepository<T> $repo */
$repo = $this->entityManager->getRepository($type);
$path = $deserialized_entity->getFullPath(AbstractStructuralDBElement::PATH_DELIMITER_ARROW);
$path = $entity->getFullPath(AbstractStructuralDBElement::PATH_DELIMITER_ARROW);
$db_elements = $repo->getEntityByPath($path, AbstractStructuralDBElement::PATH_DELIMITER_ARROW);
if ($db_elements !== []) {
//We already have the entity in the database, so we can return it
return end($db_elements);
$entity = end($db_elements);
}
//Check if we have created the entity in this request before (so we don't create multiple entities for the same path)
//Entities get saved in the cache by type and path
//We use a different cache for this then the objects created by a string value (saved in repo). However, that should not be a problem
//unless the user data has mixed structure between json data and a string path
//unless the user data has mixed structure between JSON data and a string path
if (isset($this->object_cache[$type][$path])) {
return $this->object_cache[$type][$path];
$entity = $this->object_cache[$type][$path];
} else {
//Save the entity in the cache
$this->object_cache[$type][$path] = $entity;
}
//Save the entity in the cache
$this->object_cache[$type][$path] = $deserialized_entity;
//In the next step we can denormalize the children, and add our children to the entity.
if (in_array('include_children', $context['groups'], true) && isset($data['children']) && is_array($data['children'])) {
foreach ($data['children'] as $child_data) {
$child_entity = $this->denormalize($child_data, $type, $format, array_merge($context, [self::PARENT_ELEMENT => $entity]));
if ($child_entity !== null && !$entity->getChildren()->contains($child_entity)) {
$entity->addChild($child_entity);
}
}
}
//We don't have the entity in the database, so we have to persist it
$this->entityManager->persist($deserialized_entity);
$this->entityManager->persist($entity);
return $deserialized_entity;
return $entity;
}
public function getSupportedTypes(?string $format): array

View File

@@ -202,6 +202,7 @@ class KiCadHelper
"exclude_from_bom" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBom() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBom() ?? false),
"exclude_from_board" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBoard() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBoard() ?? false),
"exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? false),
"description" => $part->getDescription(),
"fields" => []
];

View File

@@ -219,11 +219,6 @@ class EntityImporter
$entities = [$entities];
}
//The serializer has only set the children attributes. We also have to change the parent value (the real value in DB)
if ($entities[0] instanceof AbstractStructuralDBElement) {
$this->correctParentEntites($entities, null);
}
//Set the parent of the imported elements to the given options
foreach ($entities as $entity) {
if ($entity instanceof AbstractStructuralDBElement) {
@@ -297,6 +292,14 @@ class EntityImporter
return $resolver;
}
private function persistRecursively(AbstractStructuralDBElement $entity): void
{
$this->em->persist($entity);
foreach ($entity->getChildren() as $child) {
$this->persistRecursively($child);
}
}
/**
* This method deserializes the given file and writes the entities to the database (and flush the db).
* The imported elements will be checked (validated) before written to database.
@@ -322,7 +325,11 @@ class EntityImporter
//Iterate over each $entity write it to DB (the invalid entities were already filtered out).
foreach ($entities as $entity) {
$this->em->persist($entity);
if ($entity instanceof AbstractStructuralDBElement) {
$this->persistRecursively($entity);
} else {
$this->em->persist($entity);
}
}
//Save changes to database, when no error happened, or we should continue on error.
@@ -400,7 +407,7 @@ class EntityImporter
*
* @param File $file The Excel file to convert
* @param string $delimiter The CSV delimiter to use
*
*
* @return string The CSV data as string
*/
protected function convertExcelToCsv(File $file, string $delimiter = ';'): string
@@ -421,7 +428,7 @@ class EntityImporter
]);
$highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);
for ($row = 1; $row <= $highestRow; $row++) {
$rowData = [];
@@ -431,7 +438,7 @@ class EntityImporter
try {
$cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue();
$rowData[] = $cellValue ?? '';
} catch (\Exception $e) {
$this->logger->warning('Error reading cell value', [
'cell' => "{$col}{$row}",
@@ -484,21 +491,4 @@ class EntityImporter
throw $e;
}
}
/**
* This functions corrects the parent setting based on the children value of the parent.
*
* @param iterable $entities the list of entities that should be fixed
* @param AbstractStructuralDBElement|null $parent the parent, to which the entity should be set
*/
protected function correctParentEntites(iterable $entities, ?AbstractStructuralDBElement $parent = null): void
{
foreach ($entities as $entity) {
/** @var AbstractStructuralDBElement $entity */
$entity->setParent($parent);
//Do the same for the children of entity
$this->correctParentEntites($entity->getChildren(), $entity);
}
}
}

View File

@@ -117,7 +117,8 @@ final readonly class BarcodeScanResultHandler
throw InfoProviderNotActiveException::fromProvider($provider);
}
return $this->urlGenerator->generate('info_providers_create_part', ['providerKey' => $infos['providerKey'], 'providerId' => $infos['providerId']]);
//So far we can just copy over our provider info array to the URL parameters:
return $this->urlGenerator->generate('info_providers_create_part', $infos);
}
/**
@@ -146,7 +147,7 @@ final readonly class BarcodeScanResultHandler
if ($barcodeScan instanceof AmazonBarcodeScanResult) {
return $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->asin)
?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin);
?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin);
}
return null;
@@ -246,7 +247,7 @@ final readonly class BarcodeScanResultHandler
* Returns null if no provider information could be extracted from the scan result, or if the scan result type is unknown and cannot be handled by this function.
* It is not necessarily checked that the provider is active, or that the result actually exists on the provider side.
* @param BarcodeScanResultInterface $scanResult
* @return array{providerKey: string, providerId: string}|null
* @return array{providerKey: string, providerId: string, lotAmount?: float|int, lotName?: string, lotUserBarcode?: string}|null
* @throws InfoProviderNotActiveException If the scan result contains information for a provider which is currently not active in the system
*/
public function getCreateInfos(BarcodeScanResultInterface $scanResult): ?array
@@ -256,6 +257,9 @@ final readonly class BarcodeScanResultHandler
return [
'providerKey' => 'lcsc',
'providerId' => $scanResult->lcscCode,
'lotAmount' => $scanResult->quantity,
'lotName' => $scanResult->orderNumber ?? $scanResult->pickBatchNumber,
'lotUserBarcode' => $scanResult->rawInput,
];
}
@@ -276,7 +280,7 @@ final readonly class BarcodeScanResultHandler
/**
* @param EIGP114BarcodeScanResult $scanResult
* @return array{providerKey: string, providerId: string}|null
* * @return array{providerKey: string, providerId: string, lotAmount?: float|int, lotName?: string, lotUserBarcode?: string}|null
*/
private function getCreationInfoForEIGP114(EIGP114BarcodeScanResult $scanResult): ?array
{
@@ -285,23 +289,26 @@ final readonly class BarcodeScanResultHandler
// Mouser: use supplierPartNumber -> search provider -> provider_id
if ($vendor === 'mouser' && $scanResult->supplierPartNumber !== null
) {
// Search Mouser using the MPN
$dtos = $this->infoRetriever->searchByKeyword(
keyword: $scanResult->supplierPartNumber,
providers: ["mouser"]
);
// Search Mouser using the MPN
$dtos = $this->infoRetriever->searchByKeyword(
keyword: $scanResult->supplierPartNumber,
providers: ["mouser"]
);
// If there are results, provider_id is MouserPartNumber (per MouserProvider.php)
$best = $dtos[0] ?? null;
// If there are results, provider_id is MouserPartNumber (per MouserProvider.php)
$best = $dtos[0] ?? null;
if ($best !== null) {
return [
'providerKey' => 'mouser',
'providerId' => $best->provider_id,
];
}
if ($best !== null) {
return [
'providerKey' => 'mouser',
'providerId' => $best->provider_id,
'lotAmount' => $scanResult->quantity,
'lotName' => $scanResult->customerPO,
'lotUserBarcode' => $scanResult->rawInput,
];
}
return null;
return null;
}
// Digi-Key: supplierPartNumber directly
@@ -309,6 +316,9 @@ final readonly class BarcodeScanResultHandler
return [
'providerKey' => 'digikey',
'providerId' => $scanResult->supplierPartNumber ?? throw new \RuntimeException('Digikey barcode does not contain required supplier part number'),
'lotAmount' => $scanResult->quantity,
'lotName' => $scanResult->digikeyInvoiceNumber ?? $scanResult->digikeySalesOrderNumber ?? $scanResult->customerPO,
'lotUserBarcode' => $scanResult->rawInput,
];
}
@@ -317,6 +327,9 @@ final readonly class BarcodeScanResultHandler
return [
'providerKey' => 'element14',
'providerId' => $scanResult->supplierPartNumber ?? throw new \RuntimeException('Element14 barcode does not contain required supplier part number'),
'lotAmount' => $scanResult->quantity,
'lotName' => $scanResult->customerPO,
'lotUserBarcode' => $scanResult->rawInput,
];
}

View File

@@ -187,7 +187,7 @@ readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
*
* @param array<string, string> $data The fields of the EIGP114 barcode, where the key is the field name and the value is the field content
*/
public function __construct(public array $data)
public function __construct(public array $data, public readonly ?string $rawInput = null)
{
//IDs per EIGP 114.2018
$this->shipDate = $data['6D'] ?? null;
@@ -271,6 +271,8 @@ readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
*/
public static function parseFormat06Code(string $input): self
{
$rawInput = $input;
//Ensure that the input is a valid format06 code
if (!self::isFormat06Code($input)) {
throw new \InvalidArgumentException("The given input is not a valid format06 code");
@@ -306,7 +308,7 @@ readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
$results[$key] = $fieldValue;
}
return new self($results);
return new self($results, $rawInput);
}
public function getDecodedForInfoMode(): array

View File

@@ -114,10 +114,12 @@ final class BarcodeProvider implements PlaceholderProviderInterface
return '<b>IPN Barcode ERROR!</b>: '.$e->getMessage();
}
}
return null;
}
public static function getDefaultPriority(): int
{
//This provider should be checked before all others, so that nothing is delegated for part lots
return 1000;
}
}

View File

@@ -327,14 +327,14 @@ readonly class BackupManager
*/
private function restoreDatabaseFromBackup(string $tempDir): void
{
// Get database connection params from Doctrine
$connection = $this->entityManager->getConnection();
$params = $connection->getParams();
$platform = $connection->getDatabasePlatform();
// 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 AbstractMySQLPlatform) {
// Use mysql command to import - need to use shell to handle input redirection
@@ -403,7 +403,8 @@ readonly class BackupManager
// Check for SQLite database file
$sqliteFile = $tempDir . '/var/app.db';
if (file_exists($sqliteFile)) {
$targetDb = $this->projectDir . '/var/app.db';
// Use the actual configured SQLite path from Doctrine, not a hardcoded path
$targetDb = $params['path'] ?? $this->projectDir . '/var/app.db';
$this->filesystem->copy($sqliteFile, $targetDb, true);
return;
}

View File

@@ -207,6 +207,79 @@ class UpdateExecutor
}
}
/**
* Reset PHP OPcache for the web server process.
*
* OPcache in PHP-FPM is separate from CLI. After updating code files,
* PHP-FPM may still serve stale cached bytecode, causing constructor
* mismatches and 500 errors. This method creates a temporary PHP script
* in the public directory, invokes it via HTTP to reset OPcache in the
* web server context, then removes the script.
*
* @return bool Whether OPcache was successfully reset
*/
private function resetOpcache(): bool
{
$token = bin2hex(random_bytes(16));
$resetScript = $this->project_dir . '/public/_opcache_reset_' . $token . '.php';
try {
// Create a temporary PHP script that resets OPcache
$scriptContent = '<?php '
. 'if (function_exists("opcache_reset")) { opcache_reset(); echo "OK"; } '
. 'else { echo "NO_OPCACHE"; } '
. '@unlink(__FILE__);';
$this->filesystem->dumpFile($resetScript, $scriptContent);
// Try to invoke it via HTTP on localhost
$urls = [
'http://127.0.0.1/_opcache_reset_' . $token . '.php',
'http://localhost/_opcache_reset_' . $token . '.php',
];
$success = false;
foreach ($urls as $url) {
try {
$context = stream_context_create([
'http' => [
'timeout' => 5,
'ignore_errors' => true,
],
]);
$response = @file_get_contents($url, false, $context);
if ($response === 'OK') {
$this->logger->info('OPcache reset via ' . $url);
$success = true;
break;
}
} catch (\Throwable $e) {
// Try next URL
continue;
}
}
if (!$success) {
$this->logger->info('OPcache reset via HTTP not available, trying CLI fallback');
// CLI opcache_reset() only affects CLI, but try anyway
if (function_exists('opcache_reset')) {
opcache_reset();
}
}
return $success;
} catch (\Throwable $e) {
$this->logger->warning('OPcache reset failed: ' . $e->getMessage());
return false;
} finally {
// Ensure the temp script is removed
if (file_exists($resetScript)) {
@unlink($resetScript);
}
}
}
/**
* Validate that we can perform an update.
*
@@ -434,12 +507,20 @@ class UpdateExecutor
], 'Warmup cache', 120);
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
// Step 13: Disable maintenance mode
// Step 13: Reset OPcache (if available)
$stepStart = microtime(true);
$opcacheResult = $this->resetOpcache();
$log('opcache_reset', $opcacheResult
? 'Reset PHP OPcache for web server'
: 'OPcache reset skipped (not available or not needed)',
true, microtime(true) - $stepStart);
// Step 14: Disable maintenance mode
$stepStart = microtime(true);
$this->disableMaintenanceMode();
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
// Step 14: Release lock
// Step 15: Release lock
$stepStart = microtime(true);
$this->releaseLock();
@@ -494,6 +575,9 @@ class UpdateExecutor
], 'Clear cache after rollback', 120);
$log('rollback_cache', 'Cleared cache after rollback', true);
// Reset OPcache after rollback
$this->resetOpcache();
} catch (\Exception $rollbackError) {
$log('rollback_failed', 'Rollback failed: ' . $rollbackError->getMessage(), false);
}
@@ -602,6 +686,33 @@ class UpdateExecutor
}
/**
* Delete a specific update log file.
*/
public function deleteLog(string $filename): bool
{
// Validate filename pattern for security
if (!preg_match('/^update-[\w.\-]+\.log$/', $filename)) {
$this->logger->warning('Attempted to delete invalid log filename: ' . $filename);
return false;
}
$logPath = $this->project_dir . '/' . self::UPDATE_LOG_DIR . '/' . basename($filename);
if (!file_exists($logPath)) {
return false;
}
try {
$this->filesystem->remove($logPath);
$this->logger->info('Deleted update log: ' . $filename);
return true;
} catch (\Exception $e) {
$this->logger->error('Failed to delete update log: ' . $e->getMessage());
return false;
}
}
/**
* Restore from a backup file with maintenance mode and cache clearing.
*
@@ -682,12 +793,17 @@ class UpdateExecutor
$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
// Step 6: Reset OPcache
$stepStart = microtime(true);
$this->resetOpcache();
$log('opcache_reset', 'Reset PHP OPcache', true, microtime(true) - $stepStart);
// Step 7: Disable maintenance mode
$stepStart = microtime(true);
$this->disableMaintenanceMode();
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
// Step 7: Release lock
// Step 8: Release lock
$this->releaseLock();
$totalDuration = microtime(true) - $startTime;
@@ -817,7 +933,7 @@ class UpdateExecutor
'create_backup' => $createBackup,
'started_at' => (new \DateTime())->format('c'),
'current_step' => 0,
'total_steps' => 14,
'total_steps' => 15,
'step_name' => 'initializing',
'step_message' => 'Starting update process...',
'steps' => [],
@@ -890,7 +1006,7 @@ class UpdateExecutor
bool $createBackup = true,
?callable $onProgress = null
): array {
$totalSteps = 12;
$totalSteps = 13;
$currentStep = 0;
$updateProgress = function (string $stepName, string $message, bool $success = true) use (&$currentStep, $totalSteps, $targetVersion, $createBackup): void {

View File

@@ -1,5 +1,7 @@
{% extends "main_card.html.twig" %}
{% import "helper.twig" as helper %}
{% block title %}Part-DB {% trans %}update_manager.title{% endtrans %}{% endblock %}
{% block card_title %}
@@ -7,60 +9,60 @@
{% endblock %}
{% block card_content %}
<div>
<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 %}
{# 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 %}
{# 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 %}
{# 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 %}
{# 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>
<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>
@@ -100,153 +102,159 @@
<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 %}
{{ helper.boolean_badge(status.can_auto_update) }}
</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>
<tr>
<th scope="row">{% trans %}update_manager.web_updates_allowed{% endtrans %}</th>
<td>{{ helper.boolean_badge(not web_updates_disabled) }}</td>
</tr>
<tr>
<th scope="row">{% trans %}update_manager.backup_restore_allowed{% endtrans %}</th>
<td>{{ helper.boolean_badge(not backup_restore_disabled) }}</td>
</tr>
<tr>
<th scope="row">{% trans %}update_manager.backup_download_allowed{% endtrans %}</th>
<td>{{ helper.boolean_badge(not backup_download_disabled) }}</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>
</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">
{# 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 %}
{% 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 %}
</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>
{% 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 %}
{% 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>
{% 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>
{% 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 %}
{# 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 %}
{# 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;">
<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>
</thead>
<tbody>
{% for release in all_releases %}
<tr{% if release.version == status.current_version %} class="table-active"{% endif %}>
<td>
@@ -280,8 +288,8 @@
<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 }}">
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 %}
@@ -300,54 +308,69 @@
</td>
</tr>
{% endfor %}
</tbody>
</table>
</tbody>
</table>
</div>
</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;">
{# 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>
</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 class="text-end">
<div class="btn-group btn-group-sm">
<a href="{{ path('admin_update_manager_log', {filename: log.file}) }}"
class="btn btn-outline-secondary"
title="{% trans %}update_manager.view_log{% endtrans %}">
<i class="fas fa-eye"></i>
</a>
{% if is_granted('@system.manage_updates') %}
<form action="{{ path('admin_update_manager_log_delete') }}" method="post" class="d-inline"
data-turbo-confirm="{% trans %}update_manager.log.delete.confirm{% endtrans %}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_delete') }}">
<input type="hidden" name="filename" value="{{ log.file }}">
<button type="submit"
class="btn btn-outline-danger"
title="{% trans %}update_manager.delete{% endtrans %}">
<i class="fas fa-trash-alt"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% else %}
@@ -357,22 +380,39 @@
</td>
</tr>
{% endfor %}
</tbody>
</table>
</tbody>
</table>
</div>
</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;">
<div class="tab-pane fade" id="backups-tab">
{% if is_granted('@system.manage_updates') and not is_locked %}
<div class="p-2 border-bottom">
<form action="{{ path('admin_update_manager_backup') }}" method="post" class="d-inline"
data-turbo-confirm="{% trans %}update_manager.backup.create.confirm{% endtrans %}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_backup') }}">
<button type="submit" class="btn btn-sm btn-success">
<i class="fas fa-plus me-1"></i>{% trans %}update_manager.backup.create{% endtrans %}
</button>
</form>
</div>
{% endif %}
{% if is_docker %}
<div class="alert alert-info alert-sm m-2 mb-0 py-2 small">
<i class="fas fa-info-circle me-1"></i>
{% trans %}update_manager.backup.docker_warning{% endtrans %}
</div>
{% endif %}
<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>
</thead>
<tbody>
{% for backup in backups %}
<tr>
<td class="text-muted small">
@@ -383,23 +423,89 @@
{{ (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>
<div class="btn-group btn-group-sm">
{% if not backup_download_disabled and is_granted('@system.manage_updates') %}
<button type="button"
class="btn btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#downloadBackupModal-{{ loop.index }}"
title="{% trans %}update_manager.backup.download{% endtrans %}">
<i class="fas fa-download"></i>
</button>
</form>
{% endif %}
{% if not backup_restore_disabled and is_granted('@system.manage_updates') %}
<form action="{{ path('admin_update_manager_restore') }}" method="post" class="d-inline"
{{ stimulus_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-outline-warning"
title="{% trans %}update_manager.restore_backup{% endtrans %}">
<i class="fas fa-undo"></i>
</button>
</form>
{% endif %}
{% if is_granted('@system.manage_updates') %}
<form action="{{ path('admin_update_manager_backup_delete') }}" method="post" class="d-inline"
data-turbo-confirm="{% trans %}update_manager.backup.delete.confirm{% endtrans %}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_delete') }}">
<input type="hidden" name="filename" value="{{ backup.file }}">
<button type="submit"
class="btn btn-outline-danger"
title="{% trans %}update_manager.delete{% endtrans %}">
<i class="fas fa-trash-alt"></i>
</button>
</form>
{% endif %}
</div>
{% if not backup_download_disabled and is_granted('@system.manage_updates') %}
{# Per-backup download modal - no inline JS needed, CSP compatible with Turbo #}
<div class="modal fade text-start" id="downloadBackupModal-{{ loop.index }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ path('admin_update_manager_backup_download') }}" method="post" data-turbo="false">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-download me-2"></i>{% trans %}update_manager.backup.download{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-1"></i>
{% trans %}update_manager.backup.download.security_warning{% endtrans %}
</div>
<p class="text-muted small mb-3">{{ backup.file }}</p>
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_download') }}">
<input type="hidden" name="filename" value="{{ backup.file }}">
<input type="hidden" name="username" autocomplete="username">
<div class="mb-3">
<label for="downloadPassword-{{ loop.index }}" class="form-label">
{% trans %}update_manager.backup.download.password_label{% endtrans %}
</label>
<input type="password" class="form-control" id="downloadPassword-{{ loop.index }}"
name="password" required autocomplete="current-password">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% trans %}modal.cancel{% endtrans %}
</button>
<button type="submit" class="btn btn-primary" data-bs-dismiss="modal">
<i class="fas fa-download me-1"></i>{% trans %}update_manager.backup.download{% endtrans %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
</td>
</tr>
@@ -410,8 +516,9 @@
</td>
</tr>
{% endfor %}
</tbody>
</table>
</tbody>
</table>
</div>
</div>
</div>
</div>
@@ -419,5 +526,5 @@
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,8 +1,8 @@
{% macro boolean(value) %}
{% if value %}
{% trans %}bool.true{% endtrans %}
{% trans %}Yes{% endtrans %}
{% else %}
{% trans %}bool.false{% endtrans %}
{% trans %}No{% endtrans %}
{% endif %}
{% endmacro %}
@@ -14,9 +14,9 @@
{% macro bool_icon(bool) %}
{% if bool %}
<i class="fas fa-check-circle fa-fw" title="{% trans %}Yes{% endtrans %}"></i>
<i class="fas fa-check fa-fw" title="{% trans %}Yes{% endtrans %}"></i>
{% else %}
<i class="fas fa-times-circle fa-fw" title="{% trans %}No{% endtrans %}"></i>
<i class="fas fa-times fa-fw" title="{% trans %}No{% endtrans %}"></i>
{% endif %}
{% endmacro %}
@@ -24,7 +24,7 @@
{% if value %}
{% set class = class ~ ' bg-success' %}
{% else %}
{% set class = class ~ ' bg-danger' %}
{% set class = class ~ ' bg-secondary' %}
{% endif %}
<span class="{{ class }}">{{ _self.bool_icon(value) }} {{ _self.boolean(value) }}</span>

View File

@@ -47,6 +47,32 @@ final class PartLotsEndpointTest extends CrudEndpointTestCase
$this->_testGetItem(2);
}
public function testFilterByUserBarcode(): void
{
$response = self::createAuthenticatedClient()->request('GET', '/api/part_lots?user_barcode=lot2_vendor_barcode');
self::assertResponseIsSuccessful();
self::assertJsonContains([
'hydra:totalItems' => 1,
]);
$json = $response->toArray();
self::assertSame('/api/part_lots/2', $json['hydra:member'][0]['@id']);
}
public function testFilterByUserBarcodeUsingWildcard(): void
{
$response = self::createAuthenticatedClient()->request('GET', '/api/part_lots?user_barcode=lot2_%');
self::assertResponseIsSuccessful();
self::assertJsonContains([
'hydra:totalItems' => 1,
]);
$json = $response->toArray();
self::assertSame('/api/part_lots/2', $json['hydra:member'][0]['@id']);
}
public function testCreateItem(): void
{
$this->_testPostItem([

View File

@@ -121,6 +121,7 @@ final class KiCadApiControllerTest extends WebTestCase
'exclude_from_bom' => 'False',
'exclude_from_board' => 'True',
'exclude_from_sim' => 'False',
'description' => '',
'fields' =>
array(
'footprint' =>
@@ -203,6 +204,7 @@ final class KiCadApiControllerTest extends WebTestCase
'exclude_from_bom' => 'False',
'exclude_from_board' => 'True',
'exclude_from_sim' => 'False',
'description' => '',
'fields' =>
array (
'footprint' =>
@@ -318,4 +320,4 @@ final class KiCadApiControllerTest extends WebTestCase
self::assertResponseStatusCodeSame(304);
}
}
}

View File

@@ -0,0 +1,381 @@
<?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\Controller;
use App\Entity\UserSystem\User;
use App\Services\System\BackupManager;
use App\Services\System\UpdateExecutor;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
#[Group("slow")]
#[Group("DB")]
final class UpdateManagerControllerTest extends WebTestCase
{
private function loginAsAdmin($client): void
{
$entityManager = $client->getContainer()->get('doctrine')->getManager();
$userRepository = $entityManager->getRepository(User::class);
$user = $userRepository->findOneBy(['name' => 'admin']);
if (!$user) {
$this->markTestSkipped('Admin user not found');
}
$client->loginUser($user);
}
/**
* Extract a CSRF token from the rendered update manager page.
*/
private function getCsrfTokenFromPage($crawler, string $formAction): string
{
$form = $crawler->filter('form[action*="' . $formAction . '"]');
if ($form->count() === 0) {
$this->fail('Form with action containing "' . $formAction . '" not found on page');
}
return $form->filter('input[name="_token"]')->attr('value');
}
// ---- Authentication tests ----
public function testIndexPageRequiresAuth(): void
{
$client = static::createClient();
$client->request('GET', '/en/system/update-manager');
// Should deny access (401 with HTTP Basic auth in test env)
$this->assertResponseStatusCodeSame(401);
}
public function testIndexPageAccessibleByAdmin(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
$client->request('GET', '/en/system/update-manager');
$this->assertResponseIsSuccessful();
}
// ---- Backup creation tests ----
public function testCreateBackupRequiresCsrf(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
$client->request('POST', '/en/system/update-manager/backup', [
'_token' => 'invalid',
]);
// Should redirect with error flash
$this->assertResponseRedirects();
}
public function testCreateBackupWithValidCsrf(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// Load the page and extract CSRF token from the backup form
$crawler = $client->request('GET', '/en/system/update-manager');
$csrfToken = $this->getCsrfTokenFromPage($crawler, 'backup');
$client->request('POST', '/en/system/update-manager/backup', [
'_token' => $csrfToken,
]);
$this->assertResponseRedirects();
// Clean up: delete the backup that was just created
$backupManager = $client->getContainer()->get(BackupManager::class);
$backups = $backupManager->getBackups();
foreach ($backups as $backup) {
if (str_contains($backup['file'], 'manual')) {
$backupManager->deleteBackup($backup['file']);
}
}
}
public function testCreateBackupBlockedWhenLocked(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// Load the page first to get CSRF token before locking
$crawler = $client->request('GET', '/en/system/update-manager');
$csrfToken = $this->getCsrfTokenFromPage($crawler, 'backup');
// Acquire lock to simulate update in progress
$updateExecutor = $client->getContainer()->get(UpdateExecutor::class);
$updateExecutor->acquireLock();
try {
$client->request('POST', '/en/system/update-manager/backup', [
'_token' => $csrfToken,
]);
$this->assertResponseRedirects();
} finally {
// Always release lock
$updateExecutor->releaseLock();
}
}
// ---- Backup deletion tests ----
public function testDeleteBackupRequiresCsrf(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
$client->request('POST', '/en/system/update-manager/backup/delete', [
'_token' => 'invalid',
'filename' => 'test.zip',
]);
$this->assertResponseRedirects();
}
public function testDeleteBackupWithValidCsrf(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// Create a temporary backup file so the page shows the delete form
$backupManager = $client->getContainer()->get(BackupManager::class);
$backupDir = $backupManager->getBackupDir();
if (!is_dir($backupDir)) {
mkdir($backupDir, 0755, true);
}
$testFile = 'test-delete-' . uniqid() . '.zip';
file_put_contents($backupDir . '/' . $testFile, 'test');
// Load the page and extract CSRF token from the delete form
$crawler = $client->request('GET', '/en/system/update-manager');
$csrfToken = $this->getCsrfTokenFromPage($crawler, 'backup/delete');
$client->request('POST', '/en/system/update-manager/backup/delete', [
'_token' => $csrfToken,
'filename' => $testFile,
]);
$this->assertResponseRedirects();
$this->assertFileDoesNotExist($backupDir . '/' . $testFile);
}
// ---- Log deletion tests ----
public function testDeleteLogRequiresCsrf(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
$client->request('POST', '/en/system/update-manager/log/delete', [
'_token' => 'invalid',
'filename' => 'test.log',
]);
$this->assertResponseRedirects();
}
public function testDeleteLogWithValidCsrf(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// Create a temporary log file so the page shows the delete form
$projectDir = $client->getContainer()->getParameter('kernel.project_dir');
$logDir = $projectDir . '/var/log/updates';
if (!is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
$testFile = 'update-test-delete-' . uniqid() . '.log';
file_put_contents($logDir . '/' . $testFile, 'test log content');
// Load the page and extract CSRF token from the log delete form
$crawler = $client->request('GET', '/en/system/update-manager');
$csrfToken = $this->getCsrfTokenFromPage($crawler, 'log/delete');
$client->request('POST', '/en/system/update-manager/log/delete', [
'_token' => $csrfToken,
'filename' => $testFile,
]);
$this->assertResponseRedirects();
$this->assertFileDoesNotExist($logDir . '/' . $testFile);
}
// ---- Backup download tests ----
public function testDownloadBackupBlockedByDefault(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// DISABLE_BACKUP_DOWNLOAD=1 is the default in .env, so this should return 403
$client->request('POST', '/en/system/update-manager/backup/download', [
'_token' => 'any',
'filename' => 'test.zip',
'password' => 'test',
]);
$this->assertResponseStatusCodeSame(403);
}
public function testDownloadBackupRequiresPost(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// GET returns 404 since no GET route exists for this path
$client->request('GET', '/en/system/update-manager/backup/download');
$this->assertResponseStatusCodeSame(404);
}
public function testDownloadBackupRequiresAuth(): void
{
$client = static::createClient();
$client->request('POST', '/en/system/update-manager/backup/download', [
'_token' => 'any',
'filename' => 'test.zip',
'password' => 'test',
]);
// Should deny access (401 with HTTP Basic auth in test env)
$this->assertResponseStatusCodeSame(401);
}
// ---- Backup details tests ----
public function testBackupDetailsReturns404ForNonExistent(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
$client->request('GET', '/en/system/update-manager/backup/nonexistent.zip');
$this->assertResponseStatusCodeSame(404);
}
// ---- Restore tests ----
public function testRestoreBlockedWhenDisabled(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// DISABLE_BACKUP_RESTORE=1 is the default in .env, so this should return 403
$client->request('POST', '/en/system/update-manager/restore', [
'_token' => 'invalid',
'filename' => 'test.zip',
]);
$this->assertResponseStatusCodeSame(403);
}
public function testRestoreRequiresAuth(): void
{
$client = static::createClient();
$client->request('POST', '/en/system/update-manager/restore', [
'_token' => 'invalid',
'filename' => 'test.zip',
]);
$this->assertResponseStatusCodeSame(401);
}
// ---- Start update tests ----
public function testStartUpdateRequiresAuth(): void
{
$client = static::createClient();
$client->request('POST', '/en/system/update-manager/start', [
'_token' => 'invalid',
'version' => 'v1.0.0',
]);
$this->assertResponseStatusCodeSame(401);
}
public function testStartUpdateBlockedWhenWebUpdatesDisabled(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// DISABLE_WEB_UPDATES=1 is the default in .env
$client->request('POST', '/en/system/update-manager/start', [
'_token' => 'invalid',
'version' => 'v1.0.0',
]);
$this->assertResponseStatusCodeSame(403);
}
// ---- Status and progress tests ----
public function testStatusEndpointRequiresAuth(): void
{
$client = static::createClient();
$client->request('GET', '/en/system/update-manager/status');
$this->assertResponseStatusCodeSame(401);
}
public function testStatusEndpointAccessibleByAdmin(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
$client->request('GET', '/en/system/update-manager/status');
$this->assertResponseIsSuccessful();
}
public function testProgressStatusEndpointRequiresAuth(): void
{
$client = static::createClient();
$client->request('GET', '/en/system/update-manager/progress/status');
$this->assertResponseStatusCodeSame(401);
}
public function testProgressStatusEndpointAccessibleByAdmin(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
$client->request('GET', '/en/system/update-manager/progress/status');
$this->assertResponseIsSuccessful();
}
}

View File

@@ -85,4 +85,41 @@ final class StructuralElementDenormalizerTest extends WebTestCase
$result2 = $this->service->denormalize($data, Category::class, 'json', ['groups' => ['import']]);
$this->assertSame($result, $result2);
}
public function testDenormalizeViaChildren(): void
{
$data = ['name' => 'Node',
'children' => [
['name' => 'A', 'children' => [['name' => '1'], ['name' => '2']]],
['name' => 'B', 'children' => [['name' => '1'], ['name' => '2']]],
['name' => 'C', 'children' => [['name' => '1'], ['name' => '2'], ['name' => '3']]],
]
];
$result = $this->service->denormalize($data, Category::class, 'json', ['groups' => ['import', 'include_children']]);
$this->assertInstanceOf(Category::class, $result);
$this->assertCount(3, $result->getChildren());
$this->assertSame('A', $result->getChildren()[0]->getName());
$this->assertSame('B', $result->getChildren()[1]->getName());
$this->assertSame('C', $result->getChildren()[2]->getName());
//Parents should be set correctly
$this->assertSame($result, $result->getChildren()[0]->getParent());
$this->assertSame($result, $result->getChildren()[1]->getParent());
$this->assertSame($result, $result->getChildren()[2]->getParent());
$this->assertCount(2, $result->getChildren()[0]->getChildren());
$this->assertSame('1', $result->getChildren()[0]->getChildren()[0]->getName());
$this->assertSame('2', $result->getChildren()[0]->getChildren()[1]->getName());
//Parents should be set correctly
$this->assertSame($result->getChildren()[0], $result->getChildren()[0]->getChildren()[0]->getParent());
$this->assertSame($result->getChildren()[0], $result->getChildren()[0]->getChildren()[1]->getParent());
$this->assertCount(2, $result->getChildren()[1]->getChildren());
$this->assertSame('1', $result->getChildren()[1]->getChildren()[0]->getName());
$this->assertSame('2', $result->getChildren()[1]->getChildren()[1]->getName());
//Must be different instances than the children of A, because we create new elements for the same path, if we don't have them in the DB
$this->assertNotSame($result->getChildren()[0]->getChildren()[0], $result->getChildren()[1]->getChildren()[0]);
}
}

View File

@@ -115,6 +115,8 @@ final class BarcodeScanHelperTest extends WebTestCase
yield [new LocalBarcodeScanResult(LabelSupportedElement::PART_LOT, 2,BarcodeSourceType::USER_DEFINED),
'lot2_vendor_barcode'];
$input = "[)>\x1E06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04";
$eigp114Result = new EIGP114BarcodeScanResult([
'P' => '596-777A1-ND',
'1P' => 'XAF4444',
@@ -122,9 +124,9 @@ final class BarcodeScanHelperTest extends WebTestCase
'10D' => '1452',
'1T' => 'BF1103',
'4L' => 'US',
]);
], $input);
yield [$eigp114Result, "[)>\x1E06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04"];
yield [$eigp114Result, $input];
$lcscInput = '{pc:C138033,pm:RC0402FR-071ML,qty:10}';
$lcscResult = new LCSCBarcodeScanResult(

View File

@@ -82,6 +82,16 @@ final class BackupManagerTest extends KernelTestCase
$this->assertSame('2.6.0', $matches[2]);
}
public function testDeleteBackupReturnsFalseForNonExistentFile(): void
{
$this->assertFalse($this->backupManager->deleteBackup('non-existent.zip'));
}
public function testDeleteBackupReturnsFalseForNonZipFile(): void
{
$this->assertFalse($this->backupManager->deleteBackup('not-a-zip.txt'));
}
/**
* Test version parsing with different filename formats.
*/

View File

@@ -139,6 +139,38 @@ final class UpdateExecutorTest extends KernelTestCase
$this->assertFalse($this->updateExecutor->isLocked());
}
public function testDeleteLogRejectsInvalidFilename(): void
{
// Path traversal attempts should be rejected
$this->assertFalse($this->updateExecutor->deleteLog('../../../etc/passwd'));
$this->assertFalse($this->updateExecutor->deleteLog('malicious.txt'));
$this->assertFalse($this->updateExecutor->deleteLog(''));
// Must start with "update-"
$this->assertFalse($this->updateExecutor->deleteLog('backup-v1.0.0.log'));
}
public function testDeleteLogReturnsFalseForNonExistentFile(): void
{
$this->assertFalse($this->updateExecutor->deleteLog('update-nonexistent-file.log'));
}
public function testDeleteLogDeletesExistingFile(): void
{
// Create a temporary log file in the update logs directory
$projectDir = self::getContainer()->getParameter('kernel.project_dir');
$logDir = $projectDir . '/var/log/updates';
if (!is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
$testFile = 'update-test-delete-' . uniqid() . '.log';
file_put_contents($logDir . '/' . $testFile, 'test log content');
$this->assertTrue($this->updateExecutor->deleteLog($testFile));
$this->assertFileDoesNotExist($logDir . '/' . $testFile);
}
public function testEnableAndDisableMaintenanceMode(): void
{
// First, ensure maintenance mode is off

View File

@@ -12489,6 +12489,102 @@ Buerklin-API-Authentication-Server:
<target>Backup Wiederherstellungen wurden durch Serverkonfiguration deaktiviert.</target>
</segment>
</unit>
<unit id="oAb35wU" name="update_manager.backup.create">
<segment state="translated">
<source>update_manager.backup.create</source>
<target>Backup erstellen</target>
</segment>
</unit>
<unit id="ms26oI0" name="update_manager.backup.create.confirm">
<segment state="translated">
<source>update_manager.backup.create.confirm</source>
<target>Jetzt ein vollständiges Backup erstellen? Dies kann einen Moment dauern.</target>
</segment>
</unit>
<unit id="H9y0eLa" name="update_manager.backup.created">
<segment state="translated">
<source>update_manager.backup.created</source>
<target>Backup erfolgreich erstellt.</target>
</segment>
</unit>
<unit id="bMhXPVB" name="update_manager.backup.delete.confirm">
<segment state="translated">
<source>update_manager.backup.delete.confirm</source>
<target>Sind Sie sicher, dass Sie dieses Backup löschen möchten?</target>
</segment>
</unit>
<unit id="8tw67c_" name="update_manager.backup.deleted">
<segment state="translated">
<source>update_manager.backup.deleted</source>
<target>Backup erfolgreich gelöscht.</target>
</segment>
</unit>
<unit id="BzBBuqk" name="update_manager.backup.delete_error">
<segment state="translated">
<source>update_manager.backup.delete_error</source>
<target>Backup konnte nicht gelöscht werden.</target>
</segment>
</unit>
<unit id="2olmcSs" name="update_manager.log.delete.confirm">
<segment state="translated">
<source>update_manager.log.delete.confirm</source>
<target>Sind Sie sicher, dass Sie dieses Protokoll löschen möchten?</target>
</segment>
</unit>
<unit id=".ZrVHpp" name="update_manager.log.deleted">
<segment state="translated">
<source>update_manager.log.deleted</source>
<target>Protokoll erfolgreich gelöscht.</target>
</segment>
</unit>
<unit id="P2JI5Yw" name="update_manager.log.delete_error">
<segment state="translated">
<source>update_manager.log.delete_error</source>
<target>Protokoll konnte nicht gelöscht werden.</target>
</segment>
</unit>
<unit id="Yos9FWk" name="update_manager.view_log">
<segment state="translated">
<source>update_manager.view_log</source>
<target>Protokoll anzeigen</target>
</segment>
</unit>
<unit id="B9uA2va" name="update_manager.delete">
<segment state="translated">
<source>update_manager.delete</source>
<target>Löschen</target>
</segment>
</unit>
<unit id="ZtgvnXB" name="update_manager.backup.download">
<segment state="translated">
<source>update_manager.backup.download</source>
<target>Backup herunterladen</target>
</segment>
</unit>
<unit id="wxtmrnP" name="update_manager.backup.download.password_label">
<segment state="translated">
<source>update_manager.backup.download.password_label</source>
<target>Bestätigen Sie Ihr Passwort zum Herunterladen</target>
</segment>
</unit>
<unit id="MIlTTgL" name="update_manager.backup.download.security_warning">
<segment state="translated">
<source>update_manager.backup.download.security_warning</source>
<target>Backups enthalten sensible Daten, einschließlich Passwort-Hashes und Geheimnisse. Bitte bestätigen Sie Ihr Passwort, um mit dem Download fortzufahren.</target>
</segment>
</unit>
<unit id="kZPHBRt" name="update_manager.backup.download.invalid_password">
<segment state="translated">
<source>update_manager.backup.download.invalid_password</source>
<target>Ungültiges Passwort. Backup-Download verweigert.</target>
</segment>
</unit>
<unit id="AZOjnE0" name="update_manager.backup.docker_warning">
<segment state="translated">
<source>update_manager.backup.docker_warning</source>
<target>Docker-Installation erkannt. Backups werden in var/backups/ gespeichert, was kein persistentes Volume ist. Verwenden Sie die Download-Schaltfläche, um Backups extern zu speichern, oder binden Sie var/backups/ als Volume in Ihrer docker-compose.yml ein.</target>
</segment>
</unit>
<unit id="kHKChQB" name="settings.ips.conrad">
<segment state="translated">
<source>settings.ips.conrad</source>
@@ -12819,5 +12915,35 @@ Buerklin-API-Authentication-Server:
<target>Als Klartext anzeigen</target>
</segment>
</unit>
<unit id="Ehsj93c" name="modal.cancel">
<segment state="translated">
<source>modal.cancel</source>
<target>Abbrechen</target>
</segment>
</unit>
<unit id="jdpoFf2" name="update_manager.web_updates_allowed">
<segment state="translated">
<source>update_manager.web_updates_allowed</source>
<target>Web-Updates erlaubt</target>
</segment>
</unit>
<unit id="bdWa7is" name="update_manager.backup_restore_allowed">
<segment state="translated">
<source>update_manager.backup_restore_allowed</source>
<target>Backup-Wiederherstellung erlaubt</target>
</segment>
</unit>
<unit id="kllGQEN" name="update_manager.backup_download_allowed">
<segment state="translated">
<source>update_manager.backup_download_allowed</source>
<target>Backup-Download erlaubt</target>
</segment>
</unit>
<unit id="b8JxfcX" name="part.create_from_info_provider.lot_filled_from_barcode">
<segment state="translated">
<source>part.create_from_info_provider.lot_filled_from_barcode</source>
<target>[Part_lot] aus Barcode erstellt: Bitte überprüfen Sie, ob die Daten korrekt und gewünscht sind.</target>
</segment>
</unit>
</file>
</xliff>

View File

@@ -12491,6 +12491,102 @@ Buerklin-API Authentication server:
<target>Backup restore is disabled by server configuration.</target>
</segment>
</unit>
<unit id="oAb35wU" name="update_manager.backup.create">
<segment state="translated">
<source>update_manager.backup.create</source>
<target>Create Backup</target>
</segment>
</unit>
<unit id="ms26oI0" name="update_manager.backup.create.confirm">
<segment state="translated">
<source>update_manager.backup.create.confirm</source>
<target>Create a full backup now? This may take a moment.</target>
</segment>
</unit>
<unit id="H9y0eLa" name="update_manager.backup.created">
<segment state="translated">
<source>update_manager.backup.created</source>
<target>Backup created successfully.</target>
</segment>
</unit>
<unit id="bMhXPVB" name="update_manager.backup.delete.confirm">
<segment state="translated">
<source>update_manager.backup.delete.confirm</source>
<target>Are you sure you want to delete this backup?</target>
</segment>
</unit>
<unit id="8tw67c_" name="update_manager.backup.deleted">
<segment state="translated">
<source>update_manager.backup.deleted</source>
<target>Backup deleted successfully.</target>
</segment>
</unit>
<unit id="BzBBuqk" name="update_manager.backup.delete_error">
<segment state="translated">
<source>update_manager.backup.delete_error</source>
<target>Failed to delete backup.</target>
</segment>
</unit>
<unit id="2olmcSs" name="update_manager.log.delete.confirm">
<segment state="translated">
<source>update_manager.log.delete.confirm</source>
<target>Are you sure you want to delete this log?</target>
</segment>
</unit>
<unit id=".ZrVHpp" name="update_manager.log.deleted">
<segment state="translated">
<source>update_manager.log.deleted</source>
<target>Log deleted successfully.</target>
</segment>
</unit>
<unit id="P2JI5Yw" name="update_manager.log.delete_error">
<segment state="translated">
<source>update_manager.log.delete_error</source>
<target>Failed to delete log.</target>
</segment>
</unit>
<unit id="Yos9FWk" name="update_manager.view_log">
<segment state="translated">
<source>update_manager.view_log</source>
<target>View log</target>
</segment>
</unit>
<unit id="B9uA2va" name="update_manager.delete">
<segment state="translated">
<source>update_manager.delete</source>
<target>Delete</target>
</segment>
</unit>
<unit id="ZtgvnXB" name="update_manager.backup.download">
<segment state="translated">
<source>update_manager.backup.download</source>
<target>Download backup</target>
</segment>
</unit>
<unit id="wxtmrnP" name="update_manager.backup.download.password_label">
<segment state="translated">
<source>update_manager.backup.download.password_label</source>
<target>Confirm your password to download</target>
</segment>
</unit>
<unit id="MIlTTgL" name="update_manager.backup.download.security_warning">
<segment state="translated">
<source>update_manager.backup.download.security_warning</source>
<target>Backups contain sensitive data including password hashes and secrets. Please confirm your password to proceed with the download.</target>
</segment>
</unit>
<unit id="kZPHBRt" name="update_manager.backup.download.invalid_password">
<segment state="translated">
<source>update_manager.backup.download.invalid_password</source>
<target>Invalid password. Backup download denied.</target>
</segment>
</unit>
<unit id="AZOjnE0" name="update_manager.backup.docker_warning">
<segment state="translated">
<source>update_manager.backup.docker_warning</source>
<target>Docker installation detected. Backups are stored in var/backups/ which is not a persistent volume. Use the download button to save backups externally, or mount var/backups/ as a volume in your docker-compose.yml.</target>
</segment>
</unit>
<unit id="kHKChQB" name="settings.ips.conrad">
<segment state="translated">
<source>settings.ips.conrad</source>
@@ -12821,5 +12917,35 @@ Buerklin-API Authentication server:
<target>View as plain text</target>
</segment>
</unit>
<unit id="Ehsj93c" name="modal.cancel">
<segment state="translated">
<source>modal.cancel</source>
<target>Cancel</target>
</segment>
</unit>
<unit id="jdpoFf2" name="update_manager.web_updates_allowed">
<segment state="translated">
<source>update_manager.web_updates_allowed</source>
<target>Web updates allowed</target>
</segment>
</unit>
<unit id="bdWa7is" name="update_manager.backup_restore_allowed">
<segment state="translated">
<source>update_manager.backup_restore_allowed</source>
<target>Backup restore allowed</target>
</segment>
</unit>
<unit id="kllGQEN" name="update_manager.backup_download_allowed">
<segment state="translated">
<source>update_manager.backup_download_allowed</source>
<target>Backup download allowed</target>
</segment>
</unit>
<unit id="b8JxfcX" name="part.create_from_info_provider.lot_filled_from_barcode">
<segment state="translated">
<source>part.create_from_info_provider.lot_filled_from_barcode</source>
<target>[Part_lot] created from barcode: Please check if the data is correct and desired.</target>
</segment>
</unit>
</file>
</xliff>

View File

@@ -247,5 +247,11 @@
<target>Es existiert bereits eine Übersetzung für diesen Typ und Sprache!</target>
</segment>
</unit>
<unit id="zT_j_oQ" name="validator.invalid_gtin">
<segment state="translated">
<source>validator.invalid_gtin</source>
<target>Dies ist keine gültige GTIN / EAN!</target>
</segment>
</unit>
</file>
</xliff>
</xliff>