diff --git a/.env b/.env index 3ba3d65d..447ff5de 100644 --- a/.env +++ b/.env @@ -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 ################################################################################### diff --git a/VERSION b/VERSION index dbe59006..c8e38b61 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.1 +2.9.0 diff --git a/assets/controllers/elements/attachment_autocomplete_controller.js b/assets/controllers/elements/attachment_autocomplete_controller.js index 94b01136..86975c0c 100644 --- a/assets/controllers/elements/attachment_autocomplete_controller.js +++ b/assets/controllers/elements/attachment_autocomplete_controller.js @@ -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, diff --git a/assets/controllers/elements/part_select_controller.js b/assets/controllers/elements/part_select_controller.js index b69acbbc..1edbdf67 100644 --- a/assets/controllers/elements/part_select_controller.js +++ b/assets/controllers/elements/part_select_controller.js @@ -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) => { diff --git a/assets/controllers/elements/select_controller.js b/assets/controllers/elements/select_controller.js index d70e588c..11e29280 100644 --- a/assets/controllers/elements/select_controller.js +++ b/assets/controllers/elements/select_controller.js @@ -49,6 +49,7 @@ export default class extends Controller { selectOnTab: true, maxOptions: null, dropdownParent: dropdownParent, + clearAfterSelect: true, render: { item: this.renderItem.bind(this), diff --git a/assets/controllers/elements/select_multiple_controller.js b/assets/controllers/elements/select_multiple_controller.js index 17e85fae..01bbd24b 100644 --- a/assets/controllers/elements/select_multiple_controller.js +++ b/assets/controllers/elements/select_multiple_controller.js @@ -35,6 +35,8 @@ export default class extends Controller { maxItems: 1000, allowEmptyOption: true, dropdownParent: dropdownParent, + selectOnTab: true, + clearAfterSelect: true, plugins: ['remove_button'], }); } diff --git a/assets/controllers/elements/static_file_autocomplete_controller.js b/assets/controllers/elements/static_file_autocomplete_controller.js index 9703c618..bd01246a 100644 --- a/assets/controllers/elements/static_file_autocomplete_controller.js +++ b/assets/controllers/elements/static_file_autocomplete_controller.js @@ -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', diff --git a/assets/controllers/elements/structural_entity_select_controller.js b/assets/controllers/elements/structural_entity_select_controller.js index 2666530b..5c462e51 100644 --- a/assets/controllers/elements/structural_entity_select_controller.js +++ b/assets/controllers/elements/structural_entity_select_controller.js @@ -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}, diff --git a/assets/controllers/elements/tagsinput_controller.js b/assets/controllers/elements/tagsinput_controller.js index 14725227..a4b1f175 100644 --- a/assets/controllers/elements/tagsinput_controller.js +++ b/assets/controllers/elements/tagsinput_controller.js @@ -49,6 +49,7 @@ export default class extends Controller { createOnBlur: true, create: true, dropdownParent: dropdownParent, + clearAfterSelect: true, }; if(this.element.dataset.autocomplete) { diff --git a/assets/controllers/pages/parameters_autocomplete_controller.js b/assets/controllers/pages/parameters_autocomplete_controller.js index e187aa42..4abea969 100644 --- a/assets/controllers/pages/parameters_autocomplete_controller.js +++ b/assets/controllers/pages/parameters_autocomplete_controller.js @@ -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(); } -} \ No newline at end of file +} diff --git a/composer.json b/composer.json index 89e0f19b..9d51033e 100644 --- a/composer.json +++ b/composer.json @@ -177,6 +177,11 @@ "allow-contrib": false, "require": "7.4.*", "docker": true + }, + "phpstan/extension-installer": { + "ignore" : [ + "ekino/phpstan-banned-code" + ] } } } diff --git a/composer.lock b/composer.lock index 12a32829..8d224c67 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "32c5677a31185e0ed124904012500154", + "content-hash": "8fd737684b48f8d24fcad35fce37a297", "packages": [ { "name": "amphp/amp", diff --git a/docs/configuration.md b/docs/configuration.md index c5e46f21..a2f585a1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/migrations/Version20260307204859.php b/migrations/Version20260307204859.php new file mode 100644 index 00000000..325f41ab --- /dev/null +++ b/migrations/Version20260307204859.php @@ -0,0 +1,73 @@ +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)'); + } +} diff --git a/phpstan.banned_code.neon b/phpstan.banned_code.neon new file mode 100644 index 00000000..3099c384 --- /dev/null +++ b/phpstan.banned_code.neon @@ -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%' diff --git a/phpstan.dist.neon b/phpstan.dist.neon index b03c20c2..fe51518d 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -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/* diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index b4f46a27..eb80d9bc 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -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, ]); diff --git a/src/Controller/UpdateManagerController.php b/src/Controller/UpdateManagerController.php index 474c86fc..70be714d 100644 --- a/src/Controller/UpdateManagerController.php +++ b/src/Controller/UpdateManagerController.php @@ -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(); diff --git a/src/DataTables/ProjectBomEntriesDataTable.php b/src/DataTables/ProjectBomEntriesDataTable.php index 433f6f78..0d05c248 100644 --- a/src/DataTables/ProjectBomEntriesDataTable.php +++ b/src/DataTables/ProjectBomEntriesDataTable.php @@ -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) { diff --git a/src/Entity/Parts/PartLot.php b/src/Entity/Parts/PartLot.php index 53ecd3d5..a15eeb4f 100644 --- a/src/Entity/Parts/PartLot.php +++ b/src/Entity/Parts/PartLot.php @@ -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; /** diff --git a/src/Serializer/StructuralElementDenormalizer.php b/src/Serializer/StructuralElementDenormalizer.php index 9f4256f9..3da5f796 100644 --- a/src/Serializer/StructuralElementDenormalizer.php +++ b/src/Serializer/StructuralElementDenormalizer.php @@ -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 $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 diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php index be4532ce..42cc2518 100644 --- a/src/Services/EDA/KiCadHelper.php +++ b/src/Services/EDA/KiCadHelper.php @@ -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" => [] ]; diff --git a/src/Services/ImportExportSystem/EntityImporter.php b/src/Services/ImportExportSystem/EntityImporter.php index 7b928d6c..c33d6e6a 100644 --- a/src/Services/ImportExportSystem/EntityImporter.php +++ b/src/Services/ImportExportSystem/EntityImporter.php @@ -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); - } - } } diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php index 5091b987..45fdd16e 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php @@ -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, ]; } diff --git a/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php index 37c03f55..38b20562 100644 --- a/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php +++ b/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php @@ -187,7 +187,7 @@ readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface * * @param array $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 diff --git a/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php b/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php index 400fef35..9719917a 100644 --- a/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php +++ b/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php @@ -114,10 +114,12 @@ final class BarcodeProvider implements PlaceholderProviderInterface return 'IPN Barcode ERROR!: '.$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; + } } diff --git a/src/Services/System/BackupManager.php b/src/Services/System/BackupManager.php index 4946bc24..621b58d7 100644 --- a/src/Services/System/BackupManager.php +++ b/src/Services/System/BackupManager.php @@ -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; } diff --git a/src/Services/System/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php index 70aea23f..0992663e 100644 --- a/src/Services/System/UpdateExecutor.php +++ b/src/Services/System/UpdateExecutor.php @@ -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 = '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 { diff --git a/templates/admin/category_admin.html.twig b/templates/admin/category_admin.html.twig index be8c950e..e20349e9 100644 --- a/templates/admin/category_admin.html.twig +++ b/templates/admin/category_admin.html.twig @@ -40,11 +40,9 @@
{{ form_row(form.eda_info.reference_prefix) }} -
-
- {{ form_row(form.eda_info.visibility) }} -
-
+ + {{ form_row(form.eda_info.visibility) }} +
diff --git a/templates/admin/update_manager/index.html.twig b/templates/admin/update_manager/index.html.twig index 44b9f8c0..2c6db63c 100644 --- a/templates/admin/update_manager/index.html.twig +++ b/templates/admin/update_manager/index.html.twig @@ -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 %} -
+
- {# Maintenance Mode Warning #} - {% if is_maintenance %} - - {% endif %} + {# Maintenance Mode Warning #} + {% if is_maintenance %} + + {% endif %} - {# Lock Warning #} - {% if is_locked %} - - {% endif %} + {# Lock Warning #} + {% if is_locked %} + + {% endif %} - {# Web Updates Disabled Warning #} - {% if web_updates_disabled %} - - {% endif %} + {# Web Updates Disabled Warning #} + {% if web_updates_disabled %} + + {% endif %} - {# Backup Restore Disabled Warning #} - {% if backup_restore_disabled %} - - {% endif %} + {# Backup Restore Disabled Warning #} + {% if backup_restore_disabled %} + + {% endif %} -
- {# Current Version Card #} -
-
-
- {% trans %}update_manager.current_installation{% endtrans %} -
-
- - +
+ {# Current Version Card #} +
+
+
+ {% trans %}update_manager.current_installation{% endtrans %} +
+
+
+ - -
{% trans %}update_manager.version{% endtrans %} @@ -100,153 +102,159 @@
{% trans %}update_manager.auto_update_supported{% endtrans %} - {% if status.can_auto_update %} - - {% trans %}Yes{% endtrans %} - - {% else %} - - {% trans %}No{% endtrans %} - - {% endif %} + {{ helper.boolean_badge(status.can_auto_update) }}
-
- +
-
- {# Latest Version / Update Card #} -
-
-
- {% if status.update_available %} - {% trans %}update_manager.new_version_available.title{% endtrans %} - {% else %} - {% trans %}update_manager.latest_release{% endtrans %} - {% endif %} -
-
- {% if status.latest_version %} -
+ {# Latest Version / Update Card #} +
+
+
+ {% if status.update_available %} + {% trans %}update_manager.new_version_available.title{% endtrans %} + {% else %} + {% trans %}update_manager.latest_release{% endtrans %} + {% endif %} +
+
+ {% if status.latest_version %} +
{{ status.latest_tag }} - {% if not status.update_available %} -

- - {% trans %}update_manager.already_up_to_date{% endtrans %} + {% if not status.update_available %} +

+ + {% trans %}update_manager.already_up_to_date{% endtrans %} +

+ {% endif %} +
+ + {% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %} +
+ + + +
+ +
+ +
+ + +
+
+ {% endif %} + + {% if status.published_at %} +

+ + {% trans %}update_manager.released{% endtrans %}: {{ status.published_at|date('Y-m-d') }}

{% endif %} -
- - {% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %} -
- - - -
- -
- -
- - -
-
+ {% else %} +
+ +

{% trans %}update_manager.could_not_fetch_releases{% endtrans %}

+
{% endif %} - - {% if status.published_at %} -

- - {% trans %}update_manager.released{% endtrans %}: {{ status.published_at|date('Y-m-d') }} -

- {% endif %} - {% else %} -
- -

{% trans %}update_manager.could_not_fetch_releases{% endtrans %}

+
+ {% if status.latest_tag %} + {% endif %}
- {% if status.latest_tag %} - - {% endif %}
-
- {# Validation Issues #} - {% if not validation.valid %} - - {% endif %} + {# Validation Issues #} + {% if not validation.valid %} + + {% endif %} - {# Non-auto-update installations info #} - {% if not status.can_auto_update %} -
-
- {% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }} -
-

{{ status.installation.update_instructions }}

-
- {% endif %} + {# Non-auto-update installations info #} + {% if not status.can_auto_update %} +
+
+ {% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }} +
+

{{ status.installation.update_instructions }}

+
+ {% endif %} -
- {# Available Versions #} -
-
-
- {% trans %}update_manager.available_versions{% endtrans %} -
-
-
- - +
+ {# Available Versions #} +
+
+
+ {% trans %}update_manager.available_versions{% endtrans %} +
+
+
+
+ - - + + {% for release in all_releases %} {% endfor %} - -
{% trans %}update_manager.version{% endtrans %} {% trans %}update_manager.released{% endtrans %}
@@ -280,8 +288,8 @@
+ + +
-
- {# Update History & Backups #} -
-
-
- -
-
-
-
-
- - + {# Update History & Backups #} +
+
+
+ +
+
+
+
+
+
+ - - + + {% for log in update_logs %} - {% else %} @@ -357,22 +380,39 @@ {% endfor %} - -
{% trans %}update_manager.date{% endtrans %} {% trans %}update_manager.log_file{% endtrans %}
{{ log.date|date('Y-m-d H:i') }} {{ log.file }} - - - + +
+ + + + {% if is_granted('@system.manage_updates') %} +
+ + + +
+ {% endif %} +
+ + +
-
-
-
- - +
+ {% if is_granted('@system.manage_updates') and not is_locked %} +
+
+ + + +
+ {% endif %} + {% if is_docker %} +
+ + {% trans %}update_manager.backup.docker_warning{% endtrans %} +
+ {% endif %} +
+
+ - - + + {% for backup in backups %} @@ -410,8 +516,9 @@ {% endfor %} - -
{% trans %}update_manager.date{% endtrans %} {% trans %}update_manager.file{% endtrans %} {% trans %}update_manager.size{% endtrans %}
@@ -383,23 +423,89 @@ {{ (backup.size / 1024 / 1024)|number_format(1) }} MB - {% if status.can_auto_update and validation.valid and not backup_restore_disabled %} -
- - - - -
+ {% endif %} + {% if not backup_restore_disabled and is_granted('@system.manage_updates') %} +
+ + + + +
+ {% endif %} + {% if is_granted('@system.manage_updates') %} +
+ + + +
+ {% endif %} + + + {% if not backup_download_disabled and is_granted('@system.manage_updates') %} + {# Per-backup download modal - no inline JS needed, CSP compatible with Turbo #} + {% endif %}
+ + +
@@ -419,5 +526,5 @@
-
+ {% endblock %} diff --git a/templates/helper.twig b/templates/helper.twig index 9e68d56c..e8c926e7 100644 --- a/templates/helper.twig +++ b/templates/helper.twig @@ -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 %} - + {% else %} - + {% 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 %} {{ _self.bool_icon(value) }} {{ _self.boolean(value) }} diff --git a/tests/API/Endpoints/PartLotsEndpointTest.php b/tests/API/Endpoints/PartLotsEndpointTest.php index 70f1f9ab..0d48d1e7 100644 --- a/tests/API/Endpoints/PartLotsEndpointTest.php +++ b/tests/API/Endpoints/PartLotsEndpointTest.php @@ -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([ diff --git a/tests/Controller/KiCadApiControllerTest.php b/tests/Controller/KiCadApiControllerTest.php index 26a47032..8e55de85 100644 --- a/tests/Controller/KiCadApiControllerTest.php +++ b/tests/Controller/KiCadApiControllerTest.php @@ -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); } -} \ No newline at end of file +} diff --git a/tests/Controller/UpdateManagerControllerTest.php b/tests/Controller/UpdateManagerControllerTest.php new file mode 100644 index 00000000..0c2b2224 --- /dev/null +++ b/tests/Controller/UpdateManagerControllerTest.php @@ -0,0 +1,381 @@ +. + */ + +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(); + } +} diff --git a/tests/Serializer/StructuralElementDenormalizerTest.php b/tests/Serializer/StructuralElementDenormalizerTest.php index e8e46611..c81f02e3 100644 --- a/tests/Serializer/StructuralElementDenormalizerTest.php +++ b/tests/Serializer/StructuralElementDenormalizerTest.php @@ -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]); + + } } diff --git a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php index 8f8c7a18..b06653e5 100644 --- a/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php +++ b/tests/Services/LabelSystem/BarcodeScanner/BarcodeScanHelperTest.php @@ -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( diff --git a/tests/Services/System/BackupManagerTest.php b/tests/Services/System/BackupManagerTest.php index f75ef8f3..9aa92813 100644 --- a/tests/Services/System/BackupManagerTest.php +++ b/tests/Services/System/BackupManagerTest.php @@ -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. */ diff --git a/tests/Services/System/UpdateExecutorTest.php b/tests/Services/System/UpdateExecutorTest.php index 48cddf8d..8b95b3b0 100644 --- a/tests/Services/System/UpdateExecutorTest.php +++ b/tests/Services/System/UpdateExecutorTest.php @@ -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 diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index b9825030..a2884e77 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -12489,6 +12489,102 @@ Buerklin-API-Authentication-Server: Backup Wiederherstellungen wurden durch Serverkonfiguration deaktiviert. + + + update_manager.backup.create + Backup erstellen + + + + + update_manager.backup.create.confirm + Jetzt ein vollständiges Backup erstellen? Dies kann einen Moment dauern. + + + + + update_manager.backup.created + Backup erfolgreich erstellt. + + + + + update_manager.backup.delete.confirm + Sind Sie sicher, dass Sie dieses Backup löschen möchten? + + + + + update_manager.backup.deleted + Backup erfolgreich gelöscht. + + + + + update_manager.backup.delete_error + Backup konnte nicht gelöscht werden. + + + + + update_manager.log.delete.confirm + Sind Sie sicher, dass Sie dieses Protokoll löschen möchten? + + + + + update_manager.log.deleted + Protokoll erfolgreich gelöscht. + + + + + update_manager.log.delete_error + Protokoll konnte nicht gelöscht werden. + + + + + update_manager.view_log + Protokoll anzeigen + + + + + update_manager.delete + Löschen + + + + + update_manager.backup.download + Backup herunterladen + + + + + update_manager.backup.download.password_label + Bestätigen Sie Ihr Passwort zum Herunterladen + + + + + update_manager.backup.download.security_warning + Backups enthalten sensible Daten, einschließlich Passwort-Hashes und Geheimnisse. Bitte bestätigen Sie Ihr Passwort, um mit dem Download fortzufahren. + + + + + update_manager.backup.download.invalid_password + Ungültiges Passwort. Backup-Download verweigert. + + + + + update_manager.backup.docker_warning + 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. + + settings.ips.conrad @@ -12819,5 +12915,35 @@ Buerklin-API-Authentication-Server: Als Klartext anzeigen + + + modal.cancel + Abbrechen + + + + + update_manager.web_updates_allowed + Web-Updates erlaubt + + + + + update_manager.backup_restore_allowed + Backup-Wiederherstellung erlaubt + + + + + update_manager.backup_download_allowed + Backup-Download erlaubt + + + + + part.create_from_info_provider.lot_filled_from_barcode + [Part_lot] aus Barcode erstellt: Bitte überprüfen Sie, ob die Daten korrekt und gewünscht sind. + + diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index b84a1875..180d9e5e 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12491,6 +12491,102 @@ Buerklin-API Authentication server: Backup restore is disabled by server configuration. + + + update_manager.backup.create + Create Backup + + + + + update_manager.backup.create.confirm + Create a full backup now? This may take a moment. + + + + + update_manager.backup.created + Backup created successfully. + + + + + update_manager.backup.delete.confirm + Are you sure you want to delete this backup? + + + + + update_manager.backup.deleted + Backup deleted successfully. + + + + + update_manager.backup.delete_error + Failed to delete backup. + + + + + update_manager.log.delete.confirm + Are you sure you want to delete this log? + + + + + update_manager.log.deleted + Log deleted successfully. + + + + + update_manager.log.delete_error + Failed to delete log. + + + + + update_manager.view_log + View log + + + + + update_manager.delete + Delete + + + + + update_manager.backup.download + Download backup + + + + + update_manager.backup.download.password_label + Confirm your password to download + + + + + update_manager.backup.download.security_warning + Backups contain sensitive data including password hashes and secrets. Please confirm your password to proceed with the download. + + + + + update_manager.backup.download.invalid_password + Invalid password. Backup download denied. + + + + + update_manager.backup.docker_warning + 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. + + settings.ips.conrad @@ -12821,5 +12917,35 @@ Buerklin-API Authentication server: View as plain text + + + modal.cancel + Cancel + + + + + update_manager.web_updates_allowed + Web updates allowed + + + + + update_manager.backup_restore_allowed + Backup restore allowed + + + + + update_manager.backup_download_allowed + Backup download allowed + + + + + part.create_from_info_provider.lot_filled_from_barcode + [Part_lot] created from barcode: Please check if the data is correct and desired. + + diff --git a/translations/validators.de.xlf b/translations/validators.de.xlf index 17dc1641..270ee62e 100644 --- a/translations/validators.de.xlf +++ b/translations/validators.de.xlf @@ -247,5 +247,11 @@ Es existiert bereits eine Übersetzung für diesen Typ und Sprache! + + + validator.invalid_gtin + Dies ist keine gültige GTIN / EAN! + + - \ No newline at end of file +