Merge branch 'master' into mcp

This commit is contained in:
Jan Böhmer 2026-07-20 21:01:07 +02:00
commit 7c0b47d8f8
173 changed files with 27011 additions and 7959 deletions

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\Command;
use App\Services\System\AppSecretChecker;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@ -33,7 +34,9 @@ use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface;
#[AsCommand('partdb:check-requirements', 'Checks if the requirements Part-DB needs or recommends are fulfilled.')]
class CheckRequirementsCommand extends Command
{
public function __construct(protected ContainerBagInterface $params)
public function __construct(protected ContainerBagInterface $params,
private readonly AppSecretChecker $appSecretChecker
)
{
parent::__construct();
}
@ -121,6 +124,16 @@ class CheckRequirementsCommand extends Command
$io->success('Debug mode disabled.');
}
//Check if APP_SECRET has been changed from the default
if ($io->isVerbose()) {
$io->comment('Checking APP_SECRET...');
}
if ($this->appSecretChecker->isInsecureSecret()) {
$io->warning('APP_SECRET is set to the default value shipped with Part-DB. This is a security risk! Generate a new secret (e.g. using "openssl rand -hex 32") and set it as APP_SECRET in your .env.local file.');
} elseif (!$only_issues) {
$io->success('APP_SECRET has been changed from the default value.');
}
}
protected function checkPHPExtensions(SymfonyStyle $io, bool $only_issues = false): void

View file

@ -93,6 +93,8 @@ class AttachmentFileController extends AbstractController
//Set header content disposition, so that the file will be downloaded
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $attachment->getFilename());
$this->setAttachmentCSPHeaders($response);
return $response;
}
@ -112,6 +114,16 @@ class AttachmentFileController extends AbstractController
//Set header content disposition, so that the file will be downloaded
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $attachment->getFilename());
$this->setAttachmentCSPHeaders($response);
return $response;
}
private function setAttachmentCSPHeaders(Response $response): Response
{
//Set an CSP that disallow to run any scripts, styles or images from the attachment render page, as it is not used anywhere else for now and can be a security risk if used without proper precautions, so it should be opt-in
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; sandbox;");
return $response;
}

View file

@ -24,8 +24,10 @@ namespace App\Controller;
use App\DataTables\LogDataTable;
use App\Entity\Parts\Part;
use App\Services\System\AppSecretChecker;
use App\Services\System\BannerHelper;
use App\Services\System\GitVersionInfoProvider;
use App\Services\System\TrustedHostsChecker;
use App\Services\System\UpdateAvailableFacade;
use Doctrine\ORM\EntityManagerInterface;
use Omines\DataTablesBundle\DataTableFactory;
@ -36,8 +38,12 @@ use Symfony\Component\Routing\Attribute\Route;
class HomepageController extends AbstractController
{
public function __construct(private readonly DataTableFactory $dataTable, private readonly BannerHelper $bannerHelper)
{
public function __construct(
private readonly DataTableFactory $dataTable,
private readonly BannerHelper $bannerHelper,
private readonly AppSecretChecker $appSecretChecker,
private readonly TrustedHostsChecker $trustedHostsChecker,
) {
}
@ -84,6 +90,9 @@ class HomepageController extends AbstractController
'new_version_available' => $updateAvailableManager->isUpdateAvailable(),
'new_version' => $updateAvailableManager->getLatestVersionString(),
'new_version_url' => $updateAvailableManager->getLatestVersionUrl(),
'insecure_app_secret' => $this->appSecretChecker->isInsecureSecret(),
'suggested_app_secret' => $this->appSecretChecker->isInsecureSecret() ? $this->appSecretChecker->generateSecret() : null,
'trusted_hosts_unconfigured' => $this->trustedHostsChecker->isTrustedHostsUnconfigured(),
]);
}
}

View file

@ -90,7 +90,7 @@ class PartImportExportController extends AbstractController
goto ret;
}
if (!isset($errors) || $errors) {
if (!isset($errors) || $errors) { //@phpstan-ignore-line
$this->addFlash('error', 'parts.import.flash.error');
} else {
$this->addFlash('success', 'parts.import.flash.success');

View file

@ -74,7 +74,7 @@ class ToolsController extends AbstractController
'detailed_error_pages' => $this->getParameter('partdb.error_pages.show_help'),
'error_page_admin_email' => $this->getParameter('partdb.error_pages.admin_email'),
'configured_max_file_size' => $settings->system->attachments->maxFileSize,
'effective_max_file_size' => $attachmentSubmitHandler->getMaximumAllowedUploadSize(),
'effective_max_file_size' => $attachmentSubmitHandler->getMaximumEffectiveUploadSize(),
'saml_enabled' => $this->getParameter('partdb.saml.enabled'),
//PHP section

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Controller;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\Response;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parts\Category;
@ -55,7 +56,7 @@ class TreeController extends AbstractController
#[Route(path: '/category/{id}', name: 'tree_category')]
#[Route(path: '/categories', name: 'tree_category_root')]
public function categoryTree(?Category $category = null): JsonResponse
public function categoryTree(#[MapEntity(id: 'id')] ?Category $category = null): JsonResponse
{
if ($this->isGranted('@parts.read') && $this->isGranted('@categories.read')) {
$tree = $this->treeGenerator->getTreeView(Category::class, $category, 'list_parts_root');
@ -68,7 +69,7 @@ class TreeController extends AbstractController
#[Route(path: '/footprint/{id}', name: 'tree_footprint')]
#[Route(path: '/footprints', name: 'tree_footprint_root')]
public function footprintTree(?Footprint $footprint = null): JsonResponse
public function footprintTree(#[MapEntity(id: 'id')] ?Footprint $footprint = null): JsonResponse
{
if ($this->isGranted('@parts.read') && $this->isGranted('@footprints.read')) {
$tree = $this->treeGenerator->getTreeView(Footprint::class, $footprint, 'list_parts_root');
@ -80,7 +81,7 @@ class TreeController extends AbstractController
#[Route(path: '/location/{id}', name: 'tree_location')]
#[Route(path: '/locations', name: 'tree_location_root')]
public function locationTree(?StorageLocation $location = null): JsonResponse
public function locationTree(#[MapEntity(id: 'id')] ?StorageLocation $location = null): JsonResponse
{
if ($this->isGranted('@parts.read') && $this->isGranted('@storelocations.read')) {
$tree = $this->treeGenerator->getTreeView(StorageLocation::class, $location, 'list_parts_root');
@ -93,7 +94,7 @@ class TreeController extends AbstractController
#[Route(path: '/manufacturer/{id}', name: 'tree_manufacturer')]
#[Route(path: '/manufacturers', name: 'tree_manufacturer_root')]
public function manufacturerTree(?Manufacturer $manufacturer = null): JsonResponse
public function manufacturerTree(#[MapEntity(id: 'id')] ?Manufacturer $manufacturer = null): JsonResponse
{
if ($this->isGranted('@parts.read') && $this->isGranted('@manufacturers.read')) {
$tree = $this->treeGenerator->getTreeView(Manufacturer::class, $manufacturer, 'list_parts_root');
@ -106,7 +107,7 @@ class TreeController extends AbstractController
#[Route(path: '/supplier/{id}', name: 'tree_supplier')]
#[Route(path: '/suppliers', name: 'tree_supplier_root')]
public function supplierTree(?Supplier $supplier = null): JsonResponse
public function supplierTree(#[MapEntity(id: 'id')] ?Supplier $supplier = null): JsonResponse
{
if ($this->isGranted('@parts.read') && $this->isGranted('@suppliers.read')) {
$tree = $this->treeGenerator->getTreeView(Supplier::class, $supplier, 'list_parts_root');
@ -119,7 +120,7 @@ class TreeController extends AbstractController
#[Route(path: '/device/{id}', name: 'tree_device')]
#[Route(path: '/devices', name: 'tree_device_root')]
public function deviceTree(?Project $device = null): JsonResponse
public function deviceTree(#[MapEntity(id: 'id')] ?Project $device = null): JsonResponse
{
if ($this->isGranted('@projects.read')) {
$tree = $this->treeGenerator->getTreeView(Project::class, $device, 'devices');

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\DataTables;
use App\DataTables\Column\HTMLColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\PrettyBoolColumn;
use App\DataTables\Column\RowClassColumn;
@ -40,14 +41,19 @@ use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final class AttachmentDataTable implements DataTableTypeInterface
final readonly class AttachmentDataTable implements DataTableTypeInterface
{
public function __construct(private readonly TranslatorInterface $translator, private readonly EntityURLGenerator $entityURLGenerator, private readonly AttachmentManager $attachmentHelper, private readonly AttachmentURLGenerator $attachmentURLGenerator, private readonly ElementTypeNameGenerator $elementTypeNameGenerator)
public function __construct(private TranslatorInterface $translator, private EntityURLGenerator $entityURLGenerator, private AttachmentManager $attachmentHelper, private AttachmentURLGenerator $attachmentURLGenerator, private ElementTypeNameGenerator $elementTypeNameGenerator)
{
}
public function configure(DataTable $dataTable, array $options): void
{
/*************************************************************************************************************
* Avoid using render, as it has no escaping, and is a potential security risk. Use data on TextColumn or the
* HTMLColumn, if necessary
************************************************************************************************************/
$dataTable->add('dont_matter', RowClassColumn::class, [
'render' => function ($value, Attachment $context): string {
//Mark attachments yellow which have an internal file linked that doesn't exist
@ -59,10 +65,10 @@ final class AttachmentDataTable implements DataTableTypeInterface
},
]);
$dataTable->add('picture', TextColumn::class, [
$dataTable->add('picture', HTMLColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => function ($value, Attachment $context): string {
'data' => function (Attachment $context): string {
if ($context->isPicture()
&& $this->attachmentHelper->isInternalFileExisting($context)) {
@ -95,65 +101,65 @@ final class AttachmentDataTable implements DataTableTypeInterface
'orderField' => 'NATSORT(attachment.name)',
]);
$dataTable->add('attachment_type', TextColumn::class, [
$dataTable->add('attachment_type', HTMLColumn::class, [
'label' => 'attachment.table.type',
'field' => 'attachment_type.name',
'orderField' => 'NATSORT(attachment_type.name)',
'render' => fn($value, Attachment $context): string => sprintf(
'data' => fn(Attachment $context, $value): string => sprintf(
'<a href="%s">%s</a>',
$this->entityURLGenerator->editURL($context->getAttachmentType()),
htmlspecialchars((string) $value)
),
]);
$dataTable->add('element', TextColumn::class, [
$dataTable->add('element', HTMLColumn::class, [
'label' => 'attachment.table.element',
//'propertyPath' => 'element.name',
'render' => fn($value, Attachment $context): string => sprintf(
'data' => fn(Attachment $context): string => sprintf(
'<a href="%s">%s</a>',
$this->entityURLGenerator->infoURL($context->getElement()),
$this->elementTypeNameGenerator->getTypeNameCombination($context->getElement(), true)
),
]);
$dataTable->add('internal_link', TextColumn::class, [
$dataTable->add('internal_link', HTMLColumn::class, [
'label' => 'attachment.table.internal_file',
'propertyPath' => 'filename',
'orderField' => 'NATSORT(attachment.original_filename)',
'render' => function ($value, Attachment $context) {
'data' => function (Attachment $context, $value) {
if ($this->attachmentHelper->isInternalFileExisting($context)) {
return sprintf(
'<a href="%s" target="_blank" data-no-ajax>%s</a>',
$this->entityURLGenerator->viewURL($context),
htmlspecialchars($value)
htmlspecialchars((string) $value)
);
}
return $value;
}
return htmlspecialchars((string) $value);
},
]);
$dataTable->add('external_link', TextColumn::class, [
$dataTable->add('external_link', HTMLColumn::class, [
'label' => 'attachment.table.external_link',
'propertyPath' => 'host',
'orderField' => 'attachment.external_path',
'render' => function ($value, Attachment $context) {
'data' => function (Attachment $context, $value) {
if ($context->hasExternal()) {
return sprintf(
'<a href="%s" class="link-external" title="%s" target="_blank" rel="noopener">%s</a>',
htmlspecialchars((string) $context->getExternalPath()),
htmlspecialchars((string) $context->getExternalPath()),
htmlspecialchars($value),
htmlspecialchars((string) $value),
);
}
return $value;
}
return htmlspecialchars((string) $value);
},
]);
$dataTable->add('filesize', TextColumn::class, [
$dataTable->add('filesize', HTMLColumn::class, [
'label' => $this->translator->trans('attachment.table.filesize'),
'render' => function ($value, Attachment $context) {
'data' => function (Attachment $context) {
if (!$context->hasInternal()) {
return sprintf(
'<span class="badge bg-primary">
@ -168,7 +174,7 @@ final class AttachmentDataTable implements DataTableTypeInterface
'<span class="badge bg-secondary">
<i class="fas fa-hdd fa-fw"></i> %s
</span>',
$this->attachmentHelper->getHumanFileSize($context)
htmlspecialchars($this->attachmentHelper->getHumanFileSize($context))
);
}

View file

@ -78,7 +78,7 @@ class EntityColumn extends AbstractColumn
);
}
return sprintf('<i>%s</i>', $value);
return sprintf('<i>%s</i>', htmlspecialchars($value));
}
return '';

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\DataTables\Column;
use Omines\DataTablesBundle\Column\TextColumn;
/**
* A TextColumn whose value is always treated as raw HTML and therefore never passed through htmlspecialchars().
* The value returned by the 'data' option must already contain properly escaped/sanitized HTML, as it is output as-is.
*/
class HTMLColumn extends TextColumn
{
public function isRaw(): bool
{
return true;
}
}

View file

@ -87,9 +87,9 @@ class IconLinkColumn extends AbstractColumn
return sprintf(
'<a class="btn btn-primary btn-sm %s" href="%s" title="%s"><i class="%s"></i></a>',
$disabled ? 'disabled' : '',
$href,
$title,
$icon
htmlspecialchars($href),
htmlspecialchars($title ?? ''),
htmlspecialchars($icon ?? '')
);
}

View file

@ -22,9 +22,9 @@ declare(strict_types=1);
*/
namespace App\DataTables;
use App\DataTables\Column\HTMLColumn;
use App\DataTables\Column\RowClassColumn;
use Omines\DataTablesBundle\Adapter\ArrayAdapter;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableFactory;
use Omines\DataTablesBundle\DataTableTypeInterface;
@ -32,7 +32,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ErrorDataTable implements DataTableTypeInterface
final readonly class ErrorDataTable implements DataTableTypeInterface
{
public function configureOptions(OptionsResolver $optionsResolver): void
{
@ -49,6 +49,11 @@ class ErrorDataTable implements DataTableTypeInterface
public function configure(DataTable $dataTable, array $options): void
{
/*************************************************************************************************************
* Avoid using render, as it has no escaping, and is a potential security risk. Use data on TextColumn or the
* HTMLColumn, if necessary
************************************************************************************************************/
$optionsResolver = new OptionsResolver();
$this->configureOptions($optionsResolver);
$options = $optionsResolver->resolve($options);
@ -58,9 +63,9 @@ class ErrorDataTable implements DataTableTypeInterface
'render' => fn($value, $context): string => 'table-warning',
])
->add('error', TextColumn::class, [
->add('error', HTMLColumn::class, [
'label' => 'error_table.error',
'render' => fn($value, $context): string => '<i class="fa-solid fa-triangle-exclamation fa-fw"></i> ' . $value,
'data' => fn($context, $value): string => '<i class="fa-solid fa-triangle-exclamation fa-fw"></i> ' . htmlspecialchars((string) $value),
])
;

View file

@ -62,7 +62,7 @@ class PartDataTableHelper
}
if ($context->getBuiltProject() instanceof Project) {
$icon = sprintf('<i class="fa-solid fa-box-archive fa-fw me-1" title="%s"></i>',
$this->translator->trans('part.info.projectBuildPart.hint').': '.$context->getBuiltProject()->getName());
$this->translator->trans('part.info.projectBuildPart.hint').': '.htmlspecialchars($context->getBuiltProject()->getName()));
}

View file

@ -25,6 +25,7 @@ namespace App\DataTables;
use App\DataTables\Column\EnumColumn;
use App\Entity\LogSystem\LogTargetType;
use Symfony\Bundle\SecurityBundle\Security;
use App\DataTables\Column\HTMLColumn;
use App\DataTables\Column\IconLinkColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\LogEntryExtraColumn;
@ -59,7 +60,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class LogDataTable implements DataTableTypeInterface
final readonly class LogDataTable implements DataTableTypeInterface
{
protected LogEntryRepository $logRepo;
@ -95,6 +96,11 @@ class LogDataTable implements DataTableTypeInterface
public function configure(DataTable $dataTable, array $options): void
{
/*************************************************************************************************************
* Avoid using render, as it has no escaping, and is a potential security risk. Use data on TextColumn or the
* HTMLColumn, if necessary
************************************************************************************************************/
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
$options = $resolver->resolve($options);
@ -104,10 +110,10 @@ class LogDataTable implements DataTableTypeInterface
'render' => fn($value, AbstractLogEntry $context) => $this->logLevelHelper->logLevelToTableColorClass($context->getLevelString()),
]);
$dataTable->add('symbol', TextColumn::class, [
$dataTable->add('symbol', HTMLColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => fn($value, AbstractLogEntry $context): string => sprintf(
'data' => fn(AbstractLogEntry $context): string => sprintf(
'<i class="fas fa-fw %s" title="%s"></i>',
$this->logLevelHelper->logLevelToIconClass($context->getLevelString()),
$context->getLevelString()
@ -128,10 +134,10 @@ class LogDataTable implements DataTableTypeInterface
)
]);
$dataTable->add('type', TextColumn::class, [
$dataTable->add('type', HTMLColumn::class, [
'label' => 'log.type',
'propertyPath' => 'type',
'render' => function (string $value, AbstractLogEntry $context) {
'data' => function (AbstractLogEntry $context, string $value) {
$text = $this->translator->trans('log.type.'.$value);
if ($context instanceof PartStockChangedLogEntry) {
@ -149,20 +155,20 @@ class LogDataTable implements DataTableTypeInterface
'label' => 'log.level',
'visible' => 'system_log' === $options['mode'],
'propertyPath' => 'levelString',
'render' => fn(string $value, AbstractLogEntry $context) => $this->translator->trans('log.level.'.$value),
'data' => fn(AbstractLogEntry $context, string $value) => $this->translator->trans('log.level.'.$value),
]);
$dataTable->add('user', TextColumn::class, [
$dataTable->add('user', HTMLColumn::class, [
'label' => 'log.user',
'orderField' => 'NATSORT(user.name)',
'render' => function ($value, AbstractLogEntry $context): string {
'data' => function (AbstractLogEntry $context): string {
$user = $context->getUser();
//If user was deleted, show the info from the username field
if (!$user instanceof User) {
if ($context->isCLIEntry()) {
return sprintf('%s [%s]',
htmlentities((string) $context->getCLIUsername()),
htmlspecialchars((string) $context->getCLIUsername()),
$this->translator->trans('log.cli_user')
);
}
@ -170,7 +176,7 @@ class LogDataTable implements DataTableTypeInterface
//Else we just deal with a deleted user
return sprintf(
'@%s [%s]',
htmlentities($context->getUsername()),
htmlspecialchars($context->getUsername()),
$this->translator->trans('log.target_deleted'),
);
}
@ -182,7 +188,7 @@ class LogDataTable implements DataTableTypeInterface
$img_url,
$this->userAvatarHelper->getAvatarMdURL($user),
$this->urlGenerator->generate('user_info', ['id' => $user->getID()]),
htmlentities($user->getFullName(true))
htmlspecialchars($user->getFullName(true))
);
},
]);
@ -194,7 +200,7 @@ class LogDataTable implements DataTableTypeInterface
'render' => function (LogTargetType $value, AbstractLogEntry $context) {
$class = $value->toClass();
if (null !== $class) {
return $this->elementTypeNameGenerator->getLocalizedTypeLabel($class);
return $this->elementTypeNameGenerator->typeLabel($class);
}
return '';
@ -216,9 +222,9 @@ class LogDataTable implements DataTableTypeInterface
'icon' => 'fas fa-fw fa-eye',
'href' => function ($value, AbstractLogEntry $context) {
if (
$context instanceof CollectionElementDeleted ||
($context instanceof TimeTravelInterface
&& $context->hasOldDataInformation())
|| $context instanceof CollectionElementDeleted
) {
try {
$target = $this->logRepo->getTargetElement($context);

View file

@ -25,6 +25,7 @@ namespace App\DataTables;
use App\DataTables\Adapters\TwoStepORMAdapter;
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\EnumColumn;
use App\DataTables\Column\HTMLColumn;
use App\DataTables\Column\IconLinkColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
@ -58,7 +59,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
final class PartsDataTable implements DataTableTypeInterface
final readonly class PartsDataTable implements DataTableTypeInterface
{
public const LENGTH_MENU = [[10, 25, 50, 100, 250, 500, -1], [10, 25, 50, 100, 250, 500, "All"]];
@ -94,6 +95,11 @@ final class PartsDataTable implements DataTableTypeInterface
* When adding columns here, add them also to PartTableColumns enum, to make them configurable in the settings!
*************************************************************************************************************/
/*************************************************************************************************************
* Avoid using render, as it has no escaping, and is a potential security risk. Use data on TextColumn or the
* HTMLColumn, if necessary
************************************************************************************************************/
$this->csh
//Color the table rows depending on the review and favorite status
->add('row_color', RowClassColumn::class, [
@ -109,23 +115,23 @@ final class PartsDataTable implements DataTableTypeInterface
},
], visibility_configurable: false)
->add('select', SelectColumn::class, visibility_configurable: false)
->add('picture', TextColumn::class, [
->add('picture', HTMLColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderPicture($context),
'data' => fn(Part $context) => $this->partDataTableHelper->renderPicture($context),
], visibility_configurable: false)
->add('name', TextColumn::class, [
->add('name', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.name'),
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context),
'data' => fn(Part $context) => $this->partDataTableHelper->renderName($context),
'orderField' => 'NATSORT(part.name)'
])
->add('si_value', TextColumn::class, [
'label' => $this->translator->trans('part.table.si_value'),
'render' => function ($value, Part $context): string {
'data' => function (Part $context): string {
$siValue = SiValueSort::sqliteSiValue($context->getName());
if ($siValue !== null) {
//Output it as scientific number with a big E
return htmlspecialchars(sprintf('%G', $siValue));
return sprintf('%G', $siValue);
}
return '';
},
@ -156,38 +162,38 @@ final class PartsDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('part.table.manufacturer'),
'orderField' => 'NATSORT(_manufacturer.name)'
])
->add('storelocation', TextColumn::class, [
->add('storelocation', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.storeLocations'),
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
'orderField' => 'NATSORT(MIN(_storelocations.name))',
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
'data' => fn(Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
], alias: 'storage_location')
->add('amount', TextColumn::class, [
->add('amount', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.amount'),
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
'data' => fn(Part $context) => $this->partDataTableHelper->renderAmount($context),
'orderField' => 'amountSum'
])
->add('minamount', TextColumn::class, [
'label' => $this->translator->trans('part.table.minamount'),
'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format(
'data' => fn(Part $context, $value): string => $this->amountFormatter->format(
$value,
$context->getPartUnit()
)),
),
])
->add('partUnit', TextColumn::class, [
'label' => $this->translator->trans('part.table.partUnit'),
'orderField' => 'NATSORT(_partUnit.name)',
'render' => function ($value, Part $context): string {
'data' => function (Part $context): string {
$partUnit = $context->getPartUnit();
if ($partUnit === null) {
return '';
}
$tmp = htmlspecialchars($partUnit->getName());
$tmp = $partUnit->getName();
if ($partUnit->getUnit()) {
$tmp .= ' (' . htmlspecialchars($partUnit->getUnit()) . ')';
$tmp .= ' (' . $partUnit->getUnit() . ')';
}
return $tmp;
}
@ -195,14 +201,14 @@ final class PartsDataTable implements DataTableTypeInterface
->add('partCustomState', TextColumn::class, [
'label' => $this->translator->trans('part.table.partCustomState'),
'orderField' => 'NATSORT(_partCustomState.name)',
'render' => function($value, Part $context): string {
'data' => function(Part $context): string {
$partCustomState = $context->getPartCustomState();
if ($partCustomState === null) {
return '';
}
return htmlspecialchars($partCustomState->getName());
return $partCustomState->getName();
}
])
->add('addedDate', LocaleDateTimeColumn::class, [
@ -248,25 +254,25 @@ final class PartsDataTable implements DataTableTypeInterface
])
->add('eda_reference', TextColumn::class, [
'label' => $this->translator->trans('part.table.eda_reference'),
'render' => static fn($value, Part $context) => htmlspecialchars($context->getEdaInfo()->getReferencePrefix() ?? ''),
'data' => static fn(Part $context) => $context->getEdaInfo()->getReferencePrefix() ?? '',
'orderField' => 'NATSORT(part.eda_info.reference_prefix)'
])
->add('eda_value', TextColumn::class, [
'label' => $this->translator->trans('part.table.eda_value'),
'render' => static fn($value, Part $context) => htmlspecialchars($context->getEdaInfo()->getValue() ?? ''),
'data' => static fn(Part $context) => $context->getEdaInfo()->getValue() ?? '',
'orderField' => 'NATSORT(part.eda_info.value)'
])
->add('eda_status', TextColumn::class, [
->add('eda_status', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.eda_status'),
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderEdaStatus($context),
'data' => fn(Part $context) => $this->partDataTableHelper->renderEdaStatus($context),
'className' => 'text-center',
]);
//Add a column to list the projects where the part is used, when the user has the permission to see the projects
if ($this->security->isGranted('read', Project::class)) {
$this->csh->add('projects', TextColumn::class, [
$this->csh->add('projects', HTMLColumn::class, [
'label' => $this->translator->trans('project.labelp'),
'render' => function ($value, Part $context): string {
'data' => function (Part $context): string {
//Only show the first 5 projects names
$projects = $context->getProjects();
$tmp = "";
@ -286,7 +292,7 @@ final class PartsDataTable implements DataTableTypeInterface
}
return $tmp;
}
},
]);
}

View file

@ -25,6 +25,7 @@ namespace App\DataTables;
use App\DataTables\Adapters\TwoStepORMAdapter;
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\EnumColumn;
use App\DataTables\Column\HTMLColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Helpers\PartDataTableHelper;
@ -48,7 +49,7 @@ use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class ProjectBomEntriesDataTable implements DataTableTypeInterface
final readonly class ProjectBomEntriesDataTable implements DataTableTypeInterface
{
public function __construct(
protected EntityURLGenerator $entityURLGenerator,
@ -63,17 +64,22 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
public function configure(DataTable $dataTable, array $options): void
{
/*************************************************************************************************************
* Avoid using render, as it has no escaping, and is a potential security risk. Use data on TextColumn or the
* HTMLColumn, if necessary
************************************************************************************************************/
$dataTable
//->add('select', SelectColumn::class)
->add('picture', TextColumn::class, [
->add('picture', HTMLColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
if(!$context->getPart() instanceof Part) {
return '';
}
return $this->partDataTableHelper->renderPicture($context->getPart());
}
},
])
->add('id', TextColumn::class, [
@ -85,27 +91,27 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('project.bom.quantity'),
'className' => 'text-center',
'orderField' => 'bom_entry.quantity',
'render' => function ($value, ProjectBOMEntry $context): float|string {
'data' => function (ProjectBOMEntry $context): float|string {
//If we have a non-part entry, only show the rounded quantity
if (!$context->getPart() instanceof Part) {
return round($context->getQuantity());
}
//Otherwise use the unit of the part to format the quantity
return htmlspecialchars($this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit()));
return $this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit());
},
])
->add('partId', TextColumn::class, [
'label' => $this->translator->trans('project.bom.part_id'),
'visible' => true,
'orderField' => 'part.id',
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
return $context->getPart() instanceof Part ? (string) $context->getPart()->getId() : '';
},
])
->add('name', TextColumn::class, [
->add('name', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.name'),
'orderField' => 'NATSORT(part.name)',
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
if(!$context->getPart() instanceof Part) {
return htmlspecialchars((string) $context->getName());
}
@ -123,11 +129,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('part.table.ipn'),
'orderField' => 'NATSORT(part.ipn)',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
if($context->getPart() instanceof Part) {
return $context->getPart()->getIpn();
}
}
'data' => fn (ProjectBOMEntry $context) => $context->getPart()?->getIpn()
])
->add('description', MarkdownColumn::class, [
'label' => $this->translator->trans('part.table.description'),
@ -172,9 +174,9 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
},
])
->add('mountnames', TextColumn::class, [
->add('mountnames', HTMLColumn::class, [
'label' => 'project.bom.mountnames',
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
$html = '';
foreach (explode(',', $context->getMountnames()) as $mountname) {
@ -184,34 +186,34 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
},
])
->add('instockAmount', TextColumn::class, [
->add('instockAmount', HTMLColumn::class, [
'label' => 'project.bom.instockAmount',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
if ($context->getPart() !== null) {
return $this->partDataTableHelper->renderAmount($context->getPart());
}
return '';
}
},
])
->add('storelocation', TextColumn::class, [
->add('storelocation', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.storeLocations'),
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
'orderField' => 'NATSORT(MIN(_storelocations.name))',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
if ($context->getPart() !== null) {
return $this->partDataTableHelper->renderStorageLocations($context->getPart());
}
return '';
}
},
])
->add('price', TextColumn::class, [
'label' => 'project.bom.price',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
return $this->moneyFormatter->format($price->toScale(2, RoundingMode::Up)->toFloat(), null, 2, true);
},
@ -219,7 +221,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
->add('ext_price', TextColumn::class, [
'label' => 'project.bom.ext_price',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
'data' => function (ProjectBOMEntry $context) {
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
return $this->moneyFormatter->format(
$price->multipliedBy(BigDecimal::fromFloatShortest($context->getQuantity()))

View file

@ -27,6 +27,7 @@ use App\Doctrine\Functions\SiValueSort;
use App\Exceptions\InvalidRegexException;
use Doctrine\DBAL\Driver\Connection;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
use Pdo\Sqlite;
/**
* This middleware is used to add the regexp operator to the SQLite platform.
@ -44,17 +45,30 @@ class SQLiteRegexExtensionMiddlewareDriver extends AbstractDriverMiddleware
if ($params['driver'] === 'pdo_sqlite') {
$native_connection = $connection->getNativeConnection();
//Ensure that the function really exists on the connection, as it is marked as experimental according to PHP documentation
if($native_connection instanceof \PDO) {
$native_connection->sqliteCreateFunction('REGEXP', self::regexp(...), 2, \PDO::SQLITE_DETERMINISTIC);
$native_connection->sqliteCreateFunction('FIELD', self::field(...), -1, \PDO::SQLITE_DETERMINISTIC);
$native_connection->sqliteCreateFunction('FIELD2', self::field2(...), 2, \PDO::SQLITE_DETERMINISTIC);
//Create a new collation for natural sorting
$native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
//Use the new PDO::createFunction and PDO::createCollation methods if available (PHP 8.4+)
if (is_a($native_connection, Sqlite::class)) { #TODO: Remove this check when PHP 8.4 is the minimum requirement
$native_connection->createFunction('REGEXP', self::regexp(...), 2, Sqlite::DETERMINISTIC);
$native_connection->createFunction('FIELD', self::field(...), -1, Sqlite::DETERMINISTIC);
$native_connection->createFunction('FIELD2', self::field2(...), 2, Sqlite::DETERMINISTIC);
//Create a function for SI prefix value sorting
$native_connection->sqliteCreateFunction('SI_VALUE', SiValueSort::sqliteSiValue(...), 1, \PDO::SQLITE_DETERMINISTIC);
//Create a new collation for natural sorting
$native_connection->createCollation('NATURAL_CMP', strnatcmp(...));
//Create a function for SI prefix value sorting
$native_connection->createFunction('SI_VALUE', SiValueSort::sqliteSiValue(...), 1, Sqlite::DETERMINISTIC);
} else {
$native_connection->sqliteCreateFunction('REGEXP', self::regexp(...), 2, \PDO::SQLITE_DETERMINISTIC);
$native_connection->sqliteCreateFunction('FIELD', self::field(...), -1, \PDO::SQLITE_DETERMINISTIC);
$native_connection->sqliteCreateFunction('FIELD2', self::field2(...), 2, \PDO::SQLITE_DETERMINISTIC);
//Create a new collation for natural sorting
$native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
//Create a function for SI prefix value sorting
$native_connection->sqliteCreateFunction('SI_VALUE', SiValueSort::sqliteSiValue(...), 1, \PDO::SQLITE_DETERMINISTIC);
}
}
}
@ -118,4 +132,4 @@ class SQLiteRegexExtensionMiddlewareDriver extends AbstractDriverMiddleware
return $index + 1;
}
}
}

View file

@ -35,8 +35,10 @@ class SetSQLModeMiddlewareDriver extends AbstractDriverMiddleware
{
//Only set this on MySQL connections, as other databases don't support this parameter
if($params['driver'] === 'pdo_mysql') {
//1002 is \PDO::MYSQL_ATTR_INIT_COMMAND constant value
$params['driverOptions'][\PDO::MYSQL_ATTR_INIT_COMMAND] = 'SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode, \'ONLY_FULL_GROUP_BY\', \'\'))';
//PDO::MYSQL_ATTR_INIT_COMMAND is deprecated since PHP 8.5 in favor of Pdo\Mysql::ATTR_INIT_COMMAND,
//but the Pdo\Mysql class only exists since PHP 8.4. Both constants have the same value (1002).
$initCommandAttr = class_exists(\Pdo\Mysql::class) ? \Pdo\Mysql::ATTR_INIT_COMMAND : \PDO::MYSQL_ATTR_INIT_COMMAND;
$params['driverOptions'][$initCommandAttr] = 'SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode, \'ONLY_FULL_GROUP_BY\', \'\'))';
}
return parent::connect($params);

View file

@ -65,21 +65,16 @@ use Symfony\Component\Validator\Constraints as Assert;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/attachment_types/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: AttachmentType::class)],
openapi: new Operation(summary: 'Retrieves the children elements of an attachment type.'),
security: 'is_granted("@attachment_types.read")'
),
],
normalizationContext: ['groups' => ['attachment_type:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['attachment_type:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/attachment_types/{id}/children.{_format}',
operations: [
new GetCollection(openapi: new Operation(summary: 'Retrieves the children elements of an attachment type.'),
security: 'is_granted("@attachment_types.read")')
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: AttachmentType::class)
],
normalizationContext: ['groups' => ['attachment_type:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -68,23 +68,16 @@ use Symfony\Component\Validator\Constraints as Assert;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/categories/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: Category::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a category.'),
security: 'is_granted("@categories.read")'
),
],
normalizationContext: ['groups' => ['category:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['category:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/categories/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a category.'),
security: 'is_granted("@categories.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Category::class)
],
normalizationContext: ['groups' => ['category:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -67,23 +67,16 @@ use Symfony\Component\Validator\Constraints as Assert;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/footprints/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: Footprint::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a footprint.'),
security: 'is_granted("@footprints.read")'
),
],
normalizationContext: ['groups' => ['footprint:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['footprint:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/footprints/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a footprint.'),
security: 'is_granted("@footprints.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Footprint::class)
],
normalizationContext: ['groups' => ['footprint:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -28,9 +28,11 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Embeddable;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* This class represents a reference to a info provider inside a part.
* This class represents a reference to an info provider inside a part.
* @see \App\Tests\Entity\Parts\InfoProviderReferenceTest
*/
#[Embeddable]
@ -157,4 +159,44 @@ class InfoProviderReference
$ref->last_updated = new \DateTimeImmutable();
return $ref;
}
/**
* Creates a reference to an info provider based on the given parameters.
* @param string|null $provider_key
* @param string|null $provider_id
* @param string|null $provider_url
* @param \DateTimeImmutable|null $last_updated
* @return self
*/
public static function create(?string $provider_key, ?string $provider_id, ?string $provider_url, ?\DateTimeImmutable $last_updated): self
{
$ref = new InfoProviderReference();
$ref->provider_key = $provider_key;
$ref->provider_id = $provider_id;
$ref->provider_url = $provider_url;
$ref->last_updated = $last_updated;
return $ref;
}
#[Assert\Callback()]
public function validate(ExecutionContextInterface $context, mixed $payload): void
{
if ($this->provider_key === null && $this->provider_id !== null) {
$context->buildViolation('info_providers.validation.provider_id_without_key')
->atPath('provider_key')
->addViolation();
}
if ($this->provider_key === null && $this->provider_url !== null) {
$context->buildViolation('info_providers.validation.provider_url_without_key')
->atPath('provider_url')
->addViolation();
}
if ($this->provider_key !== null && $this->provider_id === null) {
$context->buildViolation('info_providers.validation.provider_key_without_id')
->atPath('provider_id')
->addViolation();
}
}
}

View file

@ -66,23 +66,16 @@ use Symfony\Component\Validator\Constraints as Assert;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/manufacturers/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: Manufacturer::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a manufacturer.'),
security: 'is_granted("@manufacturers.read")'
),
],
normalizationContext: ['groups' => ['manufacturer:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['manufacturer:write', 'company:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/manufacturers/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a manufacturer.'),
security: 'is_granted("@manufacturers.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Manufacturer::class)
],
normalizationContext: ['groups' => ['manufacturer:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -71,23 +71,16 @@ use Symfony\Component\Validator\Constraints\Length;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/measurement_units/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: MeasurementUnit::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a MeasurementUnit.'),
security: 'is_granted("@measurement_units.read")'
),
],
normalizationContext: ['groups' => ['measurement_unit:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['measurement_unit:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/measurement_units/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a MeasurementUnit.'),
security: 'is_granted("@measurement_units.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: MeasurementUnit::class)
],
normalizationContext: ['groups' => ['measurement_unit:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "unit"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -75,6 +75,7 @@ trait AdvancedPropertyTrait
*/
#[ORM\Embedded(class: InfoProviderReference::class, columnPrefix: 'provider_reference_')]
#[Groups(['full', 'part:read'])]
#[Assert\Valid()]
protected InfoProviderReference $providerReference;
/**

View file

@ -67,23 +67,16 @@ use Symfony\Component\Validator\Constraints as Assert;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/storage_locations/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: StorageLocation::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a storage location.'),
security: 'is_granted("@storelocations.read")'
),
],
normalizationContext: ['groups' => ['location:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['location:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/storage_locations/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a storage location.'),
security: 'is_granted("@storelocations.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Manufacturer::class)
],
normalizationContext: ['groups' => ['location:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -71,21 +71,16 @@ use Symfony\Component\Validator\Constraints as Assert;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/suppliers/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: Supplier::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a supplier.'),
security: 'is_granted("@manufacturers.read")'
),
],
normalizationContext: ['groups' => ['supplier:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['supplier:write', 'company:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/suppliers/{id}/children.{_format}',
operations: [new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a supplier.'),
security: 'is_granted("@manufacturers.read")'
)],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Supplier::class)
],
normalizationContext: ['groups' => ['supplier:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -71,23 +71,16 @@ use Symfony\Component\Validator\Constraints as Assert;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/currencies/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: Currency::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a currency.'),
security: 'is_granted("@currencies.read")'
),
],
normalizationContext: ['groups' => ['currency:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['currency:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/currencies/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a currency.'),
security: 'is_granted("@currencies.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Currency::class)
],
normalizationContext: ['groups' => ['currency:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "iso_code"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]

View file

@ -71,23 +71,17 @@ use Symfony\Component\Validator\Constraints\Length;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/parts/{id}/orderdetails.{_format}',
uriVariables: ['id' => new Link(toProperty: 'part', fromClass: Part::class)],
normalizationContext: ['groups' => ['orderdetail:read', 'pricedetail:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
openapi: new Operation(summary: 'Retrieves the orderdetails of a part.'),
security: 'is_granted("@parts.read")'
),
],
normalizationContext: ['groups' => ['orderdetail:read', 'orderdetail:read:standalone', 'api:basic:read', 'pricedetail:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['orderdetail:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/parts/{id}/orderdetails.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the orderdetails of a part.'),
security: 'is_granted("@parts.read")'
)
],
uriVariables: [
'id' => new Link(toProperty: 'part', fromClass: Part::class)
],
normalizationContext: ['groups' => ['orderdetail:read', 'pricedetail:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["supplierpartnr", "supplier_product_url"])]

View file

@ -66,23 +66,16 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
new GetCollection(
uriTemplate: '/projects/{id}/children.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'children', fromClass: Project::class)],
openapi: new Operation(summary: 'Retrieves the children elements of a project.'),
security: 'is_granted("@projects.read")'
),
],
normalizationContext: ['groups' => ['project:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['project:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/projects/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a project.'),
security: 'is_granted("@projects.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Project::class)
],
normalizationContext: ['groups' => ['project:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
@ -117,7 +110,7 @@ class Project extends AbstractStructuralDBElement
/**
* @var string|null The current status of the project
*/
#[Assert\Choice(['draft', 'planning', 'in_production', 'finished', 'archived'])]
#[Assert\Choice(choices: ['draft', 'planning', 'in_production', 'finished', 'archived'])]
#[Groups(['extended', 'full', 'project:read', 'project:write', 'import'])]
#[ORM\Column(type: Types::STRING, length: 64, nullable: true)]
protected ?string $status = null;

View file

@ -63,23 +63,16 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
new Post(uriTemplate: '/project_bom_entries.{_format}', securityPostDenormalize: 'is_granted("create", object)',),
new Patch(uriTemplate: '/project_bom_entries/{id}.{_format}', security: 'is_granted("edit", object)',),
new Delete(uriTemplate: '/project_bom_entries/{id}.{_format}', security: 'is_granted("delete", object)',),
new GetCollection(
uriTemplate: '/projects/{id}/bom.{_format}',
uriVariables: ['id' => new Link(fromProperty: 'bom_entries', fromClass: Project::class)],
openapi: new Operation(summary: 'Retrieves the BOM entries of the given project.'),
security: 'is_granted("@projects.read")'
),
],
normalizationContext: ['groups' => ['bom_entry:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['bom_entry:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/projects/{id}/bom.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the BOM entries of the given project.'),
security: 'is_granted("@projects.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'bom_entries', fromClass: Project::class)
],
normalizationContext: ['groups' => ['bom_entry:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", 'mountnames'])]
#[ApiFilter(RangeFilter::class, properties: ['quantity'])]

View file

@ -209,7 +209,7 @@ class AttachmentFormType extends AbstractType
public function finishView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['max_upload_size'] = $this->submitHandler->getMaximumAllowedUploadSize();
$view->vars['max_upload_size'] = $this->submitHandler->getMaximumEffectiveUploadSize();
}
public function getBlockPrefix(): string

View file

@ -0,0 +1,79 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Form\Extension;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
/**
* Catches timezone mismatches between a DateTimeInterface model value and the effective
* model_timezone configured on the field.
*
* Doctrine's UTCDateTimeImmutableType always returns UTC DateTimeImmutable objects, so any
* date/datetime field that omits `model_timezone: 'UTC'` will silently corrupt stored values
* (the transformer treats the UTC instant as if it were in the user's local timezone).
* This extension throws a \LogicException early so the mistake is caught at development time.
*/
class DateTimeModelTimezoneExtension extends AbstractTypeExtension
{
public static function getExtendedTypes(): iterable
{
return [DateTimeType::class, DateType::class];
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventListener(FormEvents::POST_SET_DATA, static function (FormEvent $event) use ($options): void {
$data = $event->getData();
if (!$data instanceof \DateTimeInterface) {
return;
}
// Resolve the effective model timezone: explicit option or the PHP default set at build time.
// This mirrors what BaseDateTimeTransformer does in its constructor.
$modelTimezone = $options['model_timezone'] ?? date_default_timezone_get();
$dataOffset = $data->getTimezone()->getOffset($data);
$modelOffset = (new \DateTimeZone($modelTimezone))->getOffset($data);
if ($dataOffset !== $modelOffset) {
throw new \LogicException(sprintf(
'Form field "%s" received a %s with timezone "%s" (UTC offset %+d s), '
. 'but the effective model_timezone is "%s" (UTC offset %+d s). '
. 'Set the "model_timezone" option to match the timezone of your data source.',
$event->getForm()->getName(),
get_debug_type($data),
$data->getTimezone()->getName(),
$dataOffset,
$modelTimezone,
$modelOffset
));
}
});
}
}

View file

@ -0,0 +1,113 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Form\InfoProviderSystem;
use App\Entity\Parts\InfoProviderReference;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class InfoProviderReferenceType extends AbstractType implements DataMapperInterface
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->setDataMapper($this)
->add('provider_key', ProviderSelectType::class, [
'label' => 'info_providers.provider_key',
'input' => 'string',
'multiple' => false,
'required' => false,
'only_active' => false,
])
->add('provider_id', TextType::class, [
'label' => 'info_providers.provider_id',
'required' => false,
])
->add('provider_url', UrlType::class, [
'label' => 'info_providers.provider_url',
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => InfoProviderReference::class,
]);
}
public function mapDataToForms(mixed $viewData, \Traversable $forms): void
{
if ($viewData === null) {
return;
}
if (!$viewData instanceof InfoProviderReference) {
return;
}
/** @var FormInterface[] $forms */
$forms = iterator_to_array($forms);
$forms['provider_key']->setData($viewData->getProviderKey());
$forms['provider_id']->setData($viewData->getProviderId());
$forms['provider_url']->setData($viewData->getProviderUrl());
}
public function mapFormsToData(\Traversable $forms, mixed &$viewData): void
{
/** @var FormInterface[] $forms */
$forms = iterator_to_array($forms);
$providerKey = $forms['provider_key']->getData();
$providerId = $forms['provider_id']->getData();
$providerUrl = $forms['provider_url']->getData();
if ($viewData === null) {
$viewData = InfoProviderReference::noProvider();
}
if (!$viewData instanceof InfoProviderReference) {
return;
}
$oldDate = $viewData->getLastUpdated();
//If all fields are empty, we set the view data to a new instance without provider information
if ($providerKey === null && $providerId === null && $providerUrl === null) {
$viewData = InfoProviderReference::noProvider();
return;
}
$viewData = InfoProviderReference::create($providerKey, $providerId, $providerUrl, $oldDate);
}
}

View file

@ -31,12 +31,12 @@ use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Translation\StaticMessage;
use Symfony\Component\Translation\TranslatableMessage;
class ProviderSelectType extends AbstractType
{
public function __construct(private readonly ProviderRegistry $providerRegistry)
{
}
public function getParent(): string
@ -46,17 +46,22 @@ class ProviderSelectType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$providers = $this->providerRegistry->getActiveProviders();
$resolver->setDefault('input', 'object');
$resolver->setAllowedTypes('input', 'string');
//Either the form returns the provider objects or their keys
$resolver->setAllowedValues('input', ['object', 'string']);
$resolver->setDefault('multiple', true);
$resolver->setDefault('choices', function (Options $options) use ($providers) {
//Only show active providers in the list, or also inactive ones
$resolver->setDefault('only_active', true);
$resolver->setAllowedTypes('only_active', 'bool');
$resolver->setDefault('choices', function (Options $options) {
$providers = $options['only_active'] ? $this->providerRegistry->getActiveProviders() : $this->providerRegistry->getProviders();
if ('object' === $options['input']) {
return $this->providerRegistry->getActiveProviders();
return $providers;
}
$tmp = [];
@ -69,20 +74,35 @@ class ProviderSelectType extends AbstractType
});
//The choice_label and choice_value only needs to be set if we want the objects
$resolver->setDefault('choice_label', function (Options $options){
$resolver->setDefault('choice_label', function (Options $options) {
if ('object' === $options['input']) {
return ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => new StaticMessage($choice?->getProviderInfo()['name']));
return ChoiceList::label($this, static fn(?InfoProviderInterface $choice
) => new StaticMessage($choice?->getProviderInfo()['name']));
}
return static fn ($choice, $key, $value) => new StaticMessage($key);
return static fn($choice, $key, $value) => new StaticMessage($key);
});
$resolver->setDefault('choice_value', function (Options $options) {
if ('object' === $options['input']) {
return ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey());
return ChoiceList::value($this,
static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey());
}
return null;
});
$resolver->setDefault('group_by', function (Options $options) {
//Do not show groups when only active providers are shown, because then all providers are active and the group would be useless
if ($options['only_active']) {
return null;
}
return function ($choice, $key, string $value) {
if ($this->providerRegistry->getProviderByKey($value)->isActive()) {
return new TranslatableMessage('info_providers.providers_list.active');
}
return new TranslatableMessage('info_providers.providers_list.disabled');
};
});
}
}

View file

@ -33,6 +33,7 @@ use App\Entity\Parts\Part;
use App\Entity\Parts\PartCustomState;
use App\Entity\PriceInformations\Orderdetail;
use App\Form\AttachmentFormType;
use App\Form\InfoProviderSystem\InfoProviderReferenceType;
use App\Form\ParameterType;
use App\Form\Part\EDA\EDAPartInfoType;
use App\Form\Type\MasterPictureAttachmentType;
@ -225,6 +226,10 @@ class PartBaseType extends AbstractType
'empty_data' => null,
'label' => 'part.gtin',
])
->add('providerReference', InfoProviderReferenceType::class, [
'label' => false,
'required' => false,
])
;
//Comment section

View file

@ -115,8 +115,10 @@ class PartLotType extends AbstractType
$builder->add('last_stocktake_at', DateTimeType::class, [
'label' => 'part_lot.edit.last_stocktake_at',
'widget' => 'single_text',
'model_timezone' => 'UTC', // The database stores the datetime in UTC, so we need to set the model timezone to UTC
'disabled' => !$this->security->isGranted('@parts_stock.stocktake'),
'required' => false,
'with_seconds' => true,
]);
}

View file

@ -38,7 +38,7 @@ class AttachmentTypeType extends AbstractType
return StructuralEntityType::class;
}
public function configureOptions(OptionsResolver $resolver)
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->define('attachment_filter_class')->allowedTypes('null', 'string')->default(null);

View file

@ -84,8 +84,10 @@ final class ProjectBuildRequest
$remaining_amount = $this->getNeededAmountForBOMEntry($bom_entry);
foreach($this->getPartLotsForBOMEntry($bom_entry) as $lot) {
//If the lot has instock use it for the build
$this->withdraw_amounts[$lot->getID()] = min($remaining_amount, $lot->getAmount());
$remaining_amount -= max(0, $this->withdraw_amounts[$lot->getID()]);
$id = $lot->getID() ?? throw new \RuntimeException("Part lot needs to have an ID!");
$this->withdraw_amounts[$id] = min($remaining_amount, $lot->getAmount());
$remaining_amount -= max(0, $this->withdraw_amounts[$id]);
}
}
}
@ -176,6 +178,10 @@ final class ProjectBuildRequest
{
$lot_id = $lot instanceof PartLot ? $lot->getID() : $lot;
if ($lot_id === null) {
throw new \InvalidArgumentException('The given lot must have an ID!');
}
if (! array_key_exists($lot_id, $this->withdraw_amounts)) {
throw new \InvalidArgumentException('The given lot is not in the withdraw amounts array!');
}
@ -192,10 +198,12 @@ final class ProjectBuildRequest
{
if ($lot instanceof PartLot) {
$lot_id = $lot->getID();
} elseif (is_int($lot)) {
$lot_id = $lot;
} else {
throw new \InvalidArgumentException('The given lot must be an instance of PartLot or an ID of a PartLot!');
$lot_id = $lot;
}
if ($lot_id === null) {
throw new \InvalidArgumentException('The given lot must have an ID!');
}
$this->withdraw_amounts[$lot_id] = $amount;
@ -296,7 +304,7 @@ final class ProjectBuildRequest
* @param bool $dont_check_quantity
* @return $this
*/
public function setDontCheckQuantity(bool $dont_check_quantity): ProjectBuildRequest
public function setDontCheckQuantity(bool $dont_check_quantity): self
{
$this->dont_check_quantity = $dont_check_quantity;
return $this;

View file

@ -158,7 +158,6 @@ class DBElementRepository extends EntityRepository
{
$reflection = new ReflectionClass($element::class);
$property = $reflection->getProperty($field);
$property->setAccessible(true);
$property->setValue($element, $new_value);
}
}

View file

@ -63,7 +63,9 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
'eda_exclude_bom' => 'eda_exclude_from_bom',
'eda_exclude_board' => 'eda_exclude_from_board',
'eda_exclude_sim' => 'eda_exclude_from_sim',
'eda_invisible' => 'eda_visibility',
//NOTE: "eda_invisible" is intentionally NOT mapped here: it is the *inverse* of
//"eda_visibility", so a plain key rename would store the wrong value. It is handled
//(and inverted) explicitly in applyEdaFields().
];
public function __construct(
@ -235,6 +237,9 @@ class PartNormalizer implements NormalizerInterface, DenormalizerInterface, Norm
}
if (isset($data['eda_visibility']) && $data['eda_visibility'] !== '') {
$edaInfo->setVisibility(filter_var($data['eda_visibility'], FILTER_VALIDATE_BOOLEAN));
} elseif (isset($data['eda_invisible']) && $data['eda_invisible'] !== '') {
//"eda_invisible" is the inverse convenience alias of "eda_visibility"
$edaInfo->setVisibility(!filter_var($data['eda_invisible'], FILTER_VALIDATE_BOOLEAN));
}
}

View file

@ -24,6 +24,7 @@ declare(strict_types=1);
namespace App\Services\AI;
use App\Settings\AISettings\LMStudioSettings;
use App\Settings\AISettings\OllamaSettings;
use App\Settings\AISettings\OpenRouterSettings;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
@ -32,6 +33,7 @@ enum AIPlatforms: string implements TranslatableInterface
{
case OPENROUTER = 'openrouter';
case LMSTUDIO = 'lmstudio';
case OLLAMA = 'ollama';
/**
* Returns the name attribute of the service tag for this platform, which is used to register the platform in the AIPlatformRegistry
@ -52,6 +54,7 @@ enum AIPlatforms: string implements TranslatableInterface
return match ($this) {
self::LMSTUDIO => LMStudioSettings::class,
self::OPENROUTER => OpenRouterSettings::class,
self::OLLAMA => OllamaSettings::class,
};
}

View file

@ -69,7 +69,7 @@ class AttachmentSubmitHandler
protected const BLACKLISTED_EXTENSIONS = ['php', 'phtml', 'php3', 'ph3', 'php4', 'ph4', 'php5', 'ph5', 'phtm', 'sh',
'asp', 'cgi', 'py', 'pl', 'exe', 'aspx', 'js', 'mjs', 'jsp', 'css', 'jar', 'html', 'htm', 'shtm', 'shtml', 'htaccess',
'htpasswd', ''];
'htpasswd', 'phar', 'phps', ''];
public function __construct(
protected AttachmentPathResolver $pathResolver,
@ -217,6 +217,14 @@ class AttachmentSubmitHandler
//If no file was uploaded, but we have base64 encoded data, create a file from it
if (!$file && $upload->data !== null) {
if (strlen($upload->data) > $this->getMaximumUserConfiguredUploadSize() * 4 / 3) { //Base64 encoding increases the size of the data by 4/3, so we have to check for that
throw new RuntimeException(
sprintf(
'The given base64 data is too big! Maximum size is %.1f MB!',
$this->getMaximumUserConfiguredUploadSize() / 1000 / 1000
));
}
$file = new UploadedBase64EncodedFile(new Base64EncodedFile($upload->data), $upload->filename ?? 'base64');
}
@ -225,6 +233,15 @@ class AttachmentSubmitHandler
//When a file is given then upload it, otherwise check if we need to download the URL
if ($file instanceof UploadedFile) {
//Check the file size, to avoid uploading too big files.
//The file is not necessarily validated as it can also come from an Base64 source
if ($file->getSize() > $this->getMaximumUserConfiguredUploadSize()) {
throw new RuntimeException(
sprintf(
'The uploaded file is too big! Maximum size is %.1f MB!',
$this->getMaximumUserConfiguredUploadSize() / 1000 / 1000
));
}
$this->upload($attachment, $file, $secure_attachment);
} elseif ($upload->downloadUrl && $attachment->hasExternal()) {
@ -399,9 +416,30 @@ class AttachmentSubmitHandler
//Open a temporary file in the attachment folder
$fs->mkdir($attachment_folder);
$fileHandler = fopen($tmp_path, 'wb');
$bytesDownloaded = 0;
$maxSize = $this->getMaximumUserConfiguredUploadSize(); //We use the maximum user configured size here, PHPs limits dont apply
//Write the downloaded data to file
foreach ($this->httpClient->stream($response) as $chunk) {
fwrite($fileHandler, $chunk->getContent());
$content = $chunk->getContent();
$bytesDownloaded += strlen($content);
//Ensure the size does not get too large to avoid filling up the disk easily.
//If the file is too big, cancel the download and delete the temporary file.
if ($bytesDownloaded > $maxSize) {
$response->cancel();
fclose($fileHandler);
unlink($tmp_path); //Delete the temporary file, because it is too big
throw new AttachmentDownloadException(
sprintf(
'The downloaded file is too big! Maximum size is %.1f MB!',
$maxSize / 1000 / 1000
));
}
fwrite($fileHandler, $content);
}
fclose($fileHandler);
@ -505,10 +543,10 @@ class AttachmentSubmitHandler
}
/*
* Returns the maximum allowed upload size in bytes.
* Returns the maximum effective upload size in bytes.
* This is the minimum value of Part-DB max_file_size, and php.ini's post_max_size and upload_max_filesize.
*/
public function getMaximumAllowedUploadSize(): int
public function getMaximumEffectiveUploadSize(): int
{
if ($this->max_upload_size_bytes) {
return $this->max_upload_size_bytes;
@ -523,6 +561,15 @@ class AttachmentSubmitHandler
return $this->max_upload_size_bytes;
}
/**
* Returns the maximum user configured upload size in bytes.
* @return int
*/
public function getMaximumUserConfiguredUploadSize(): int
{
return $this->parseFileSizeString($this->settings->maxFileSize);
}
/**
* Sanitizes the given SVG file, if the attachment is an internal SVG file.
* @param Attachment $attachment
@ -543,8 +590,10 @@ class AttachmentSubmitHandler
return $attachment;
}
$guessed_mime_type = $this->mimeTypes->guessMimeType($path);
//Check if the file is an SVG
if ($attachment->getExtension() === "svg") {
if ($guessed_mime_type === "image/svg+xml" || $attachment->getExtension() === "svg") {
$this->SVGSanitizer->sanitizeFile($path);
}

View file

@ -29,13 +29,13 @@ use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Service for validating BOM import data with comprehensive validation rules
* and user-friendly error messages.
* and user-friendly error messages. The results are not HTML safe, and must be escaped before display!
*/
class BOMValidationService
readonly class BOMValidationService
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TranslatorInterface $translator
private EntityManagerInterface $entityManager,
private TranslatorInterface $translator
) {
}
@ -473,4 +473,4 @@ class BOMValidationService
: 0,
];
}
}
}

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Helpers\FilenameSanatizer;
@ -179,7 +180,11 @@ class EntityExporter
foreach ($columns as $column) {
$cellCoordinate = Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
$worksheet->setCellValue($cellCoordinate, $column);
if (is_numeric(trim($column, '"'))) { // Check if the column value is numeric after trimming quotes, as values are surrounded by quotes in CSV
$worksheet->setCellValueExplicit($cellCoordinate, $column, DataType::TYPE_NUMERIC);
} else {
$worksheet->setCellValueExplicit($cellCoordinate, $column, DataType::TYPE_STRING);
}
$colIndex++;
}
$rowIndex++;
@ -268,7 +273,7 @@ class EntityExporter
//Remove percent for fallback
$fallback = str_replace("%", "_", $filename);
// Create the disposition of the file
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,

View file

@ -233,7 +233,6 @@ trait PKImportHelperTrait
$reflectionClass = new \ReflectionClass($entity);
$property = $reflectionClass->getProperty('addedDate');
$property->setAccessible(true);
$property->setValue($entity, $date);
}

View file

@ -42,7 +42,7 @@ final class DTOJsonSchemaConverter
public function getJSONSchema(): array
{
return [
'name' => 'clock',
'name' => 'part_detail',
'strict' => true,
'schema' => [
'type' => 'object',

View file

@ -282,6 +282,10 @@ final class AIWebProvider implements InfoProviderInterface
try {
$aiPlatform = $this->AIPlatformRegistry->getPlatform($this->settings->platform ?? throw new \RuntimeException('No AI platform selected') );
// AI inference can take much longer than PHP's default max_execution_time (typically 30s).
// The HTTP client timeout already enforces the configured limit; disable PHP's constraint here.
set_time_limit(0);
//'openai/gpt-5-mini'
$result = $aiPlatform->invoke($this->settings->model ?? throw new \RuntimeException('No model selected'), $input, [
'response_format' => [

View file

@ -136,7 +136,9 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
}
}
$response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
//First we try the search v3 endpoint, which seems to give better results including pictures, but it only works
//on quite exact mpn matches
$response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v3/global", [
'headers' => [
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
],
@ -147,20 +149,29 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
$arr = $response->toArray();
// Get products list
$products = $arr['result']['productSearchResultVO']['productList'] ?? [];
// Get product tip
$tipProductCode = $arr['result']['tipProductDetailUrlVO']['productCode'] ?? null;
//If we get exact matches, use them
if (!empty($arr['result']['exactMatchResult'])) {
$products = $arr['result']['exactMatchResult'];
} else { //Otherwise fallback onto the third search endpoint, which has a worse data quality but is more likely to return results for vague search terms
$response = $this->lcscClient->request('POST', self::ENDPOINT_URL."/search/third", [
'headers' => [
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
],
'json' => [
'keyword' => $term,
'currentPage' => 1,
'pageSize' => 10,
],
]);
$arr = $response->toArray();
// Get products list
$products = $arr['result']['productList'] ?? [];
}
$result = [];
// LCSC does not display LCSC codes in the search, instead taking you directly to the
// detailed product listing. It does so utilizing a product tip field.
// If product tip exists and there are no products in the product list try a detail query
if (count($products) === 0 && $tipProductCode !== null) {
$result[] = $this->queryDetail($tipProductCode, $lightweight);
}
foreach ($products as $product) {
$result[] = $this->getPartDetail($product, $lightweight);
}
@ -219,7 +230,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
provider_key: $this->getProviderKey(),
provider_id: $product['productCode'],
name: $product['productModel'],
description: $this->sanitizeField($product['productIntroEn']),
description: $this->sanitizeField($product['productIntroEn']) ?? '',
category: $this->sanitizeField($category ?? null),
manufacturer: $this->sanitizeField($product['brandNameEn'] ?? null),
mpn: $this->sanitizeField($product['productModel'] ?? null),
@ -383,7 +394,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
]);
} else {
// Search API call for other terms
$responses[$keyword] = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
$responses[$keyword] = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v3/global", [
'headers' => [
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
],

View file

@ -67,8 +67,8 @@ class BarcodeHelper
{
$svg = $this->barcodeAsSVG($content, $type);
$base64 = $this->dataUri($svg, 'image/svg+xml');
$alt_text ??= $content;
$alt_text ??= htmlspecialchars($content);
return '<img src="'.$base64.'" width="'.$width.'" style="min-height: 25px;" alt="'.$alt_text.'"/>';
}
@ -94,4 +94,4 @@ class BarcodeHelper
return $repr;
}
}
}

View file

@ -44,9 +44,9 @@ namespace App\Services\LabelSystem\PlaceholderProviders;
use App\Entity\Base\AbstractDBElement;
use App\Services\ElementTypeNameGenerator;
final class AbstractDBElementProvider implements PlaceholderProviderInterface
final readonly class AbstractDBElementProvider implements PlaceholderProviderInterface
{
public function __construct(private readonly ElementTypeNameGenerator $elementTypeNameGenerator)
public function __construct(private ElementTypeNameGenerator $elementTypeNameGenerator)
{
}
@ -54,7 +54,7 @@ final class AbstractDBElementProvider implements PlaceholderProviderInterface
{
if ($label_target instanceof AbstractDBElement) {
if ('[[TYPE]]' === $placeholder) {
return $this->elementTypeNameGenerator->getLocalizedTypeLabel($label_target);
return $this->elementTypeNameGenerator->typeLabel($label_target);
}
if ('[[ID]]' === $placeholder) {

View file

@ -31,11 +31,11 @@ use App\Services\LabelSystem\LabelBarcodeGenerator;
use App\Services\LabelSystem\Barcodes\BarcodeContentGenerator;
use Com\Tecnick\Barcode\Exception;
final class BarcodeProvider implements PlaceholderProviderInterface
final readonly class BarcodeProvider implements PlaceholderProviderInterface
{
public function __construct(private readonly LabelBarcodeGenerator $barcodeGenerator,
private readonly BarcodeContentGenerator $barcodeContentGenerator,
private readonly BarcodeHelper $barcodeHelper)
public function __construct(private LabelBarcodeGenerator $barcodeGenerator,
private BarcodeContentGenerator $barcodeContentGenerator,
private BarcodeHelper $barcodeHelper)
{
}

View file

@ -53,11 +53,11 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
* Provides Placeholders for infos about global infos like Installation name or datetimes.
* @see \App\Tests\Services\LabelSystem\PlaceholderProviders\GlobalProvidersTest
*/
final class GlobalProviders implements PlaceholderProviderInterface
final readonly class GlobalProviders implements PlaceholderProviderInterface
{
public function __construct(
private readonly Security $security,
private readonly UrlGeneratorInterface $url_generator,
private Security $security,
private UrlGeneratorInterface $url_generator,
private CustomizationSettings $customizationSettings,
)
{
@ -66,13 +66,13 @@ final class GlobalProviders implements PlaceholderProviderInterface
public function replace(string $placeholder, object $label_target, array $options = []): ?string
{
if ('[[INSTALL_NAME]]' === $placeholder) {
return $this->customizationSettings->instanceName;
return htmlspecialchars($this->customizationSettings->instanceName);
}
$user = $this->security->getUser();
if ('[[USERNAME]]' === $placeholder) {
if ($user instanceof User) {
return $user->getName();
return htmlspecialchars($user->getName());
}
return 'anonymous';
@ -80,7 +80,7 @@ final class GlobalProviders implements PlaceholderProviderInterface
if ('[[USERNAME_FULL]]' === $placeholder) {
if ($user instanceof User) {
return $user->getFullName(true);
return htmlspecialchars($user->getFullName(true));
}
return 'anonymous';

View file

@ -51,7 +51,7 @@ final class NamedElementProvider implements PlaceholderProviderInterface
public function replace(string $placeholder, object $label_target, array $options = []): ?string
{
if ($label_target instanceof NamedElementInterface && '[[NAME]]' === $placeholder) {
return $label_target->getName();
return htmlspecialchars($label_target->getName());
}
return null;

View file

@ -52,9 +52,9 @@ use Locale;
/**
* @see \App\Tests\Services\LabelSystem\PlaceholderProviders\PartLotProviderTest
*/
final class PartLotProvider implements PlaceholderProviderInterface
final readonly class PartLotProvider implements PlaceholderProviderInterface
{
public function __construct(private readonly LabelTextReplacer $labelTextReplacer, private readonly AmountFormatter $amountFormatter)
public function __construct(private LabelTextReplacer $labelTextReplacer, private AmountFormatter $amountFormatter)
{
}
@ -66,11 +66,11 @@ final class PartLotProvider implements PlaceholderProviderInterface
}
if ('[[LOT_NAME]]' === $placeholder) {
return $label_target->getName();
return htmlspecialchars($label_target->getName());
}
if ('[[LOT_COMMENT]]' === $placeholder) {
return $label_target->getComment();
return htmlspecialchars($label_target->getComment());
}
if ('[[EXPIRATION_DATE]]' === $placeholder) {
@ -95,19 +95,19 @@ final class PartLotProvider implements PlaceholderProviderInterface
}
if ('[[LOCATION]]' === $placeholder) {
return $label_target->getStorageLocation() instanceof StorageLocation ? $label_target->getStorageLocation()->getName() : '';
return $label_target->getStorageLocation() instanceof StorageLocation ? htmlspecialchars($label_target->getStorageLocation()->getName()) : '';
}
if ('[[LOCATION_FULL]]' === $placeholder) {
return $label_target->getStorageLocation() instanceof StorageLocation ? $label_target->getStorageLocation()->getFullPath() : '';
return $label_target->getStorageLocation() instanceof StorageLocation ? htmlspecialchars($label_target->getStorageLocation()->getFullPath()) : '';
}
if ('[[OWNER]]' === $placeholder) {
return $label_target->getOwner() instanceof User ? $label_target->getOwner()->getFullName() : '';
return $label_target->getOwner() instanceof User ? htmlspecialchars($label_target->getOwner()->getFullName()) : '';
}
if ('[[OWNER_USERNAME]]' === $placeholder) {
return $label_target->getOwner() instanceof User ? $label_target->getOwner()->getUsername() : '';
return $label_target->getOwner() instanceof User ? htmlspecialchars($label_target->getOwner()->getUsername()) : '';
}
return $this->labelTextReplacer->handlePlaceholder($placeholder, $label_target->getPart());

View file

@ -54,11 +54,11 @@ use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @see \App\Tests\Services\LabelSystem\PlaceholderProviders\PartProviderTest
*/
final class PartProvider implements PlaceholderProviderInterface
final readonly class PartProvider implements PlaceholderProviderInterface
{
private readonly MarkdownConverter $inlineConverter;
private MarkdownConverter $inlineConverter;
public function __construct(private readonly SIFormatter $siFormatter, private readonly TranslatorInterface $translator)
public function __construct(private SIFormatter $siFormatter, private TranslatorInterface $translator)
{
$environment = new Environment();
$environment->addExtension(new InlinesOnlyExtension());
@ -72,27 +72,27 @@ final class PartProvider implements PlaceholderProviderInterface
}
if ('[[CATEGORY]]' === $placeholder) {
return $part->getCategory() instanceof Category ? $part->getCategory()->getName() : '';
return $part->getCategory() instanceof Category ? htmlspecialchars($part->getCategory()->getName()) : '';
}
if ('[[CATEGORY_FULL]]' === $placeholder) {
return $part->getCategory() instanceof Category ? $part->getCategory()->getFullPath() : '';
return $part->getCategory() instanceof Category ? htmlspecialchars($part->getCategory()->getFullPath()) : '';
}
if ('[[MANUFACTURER]]' === $placeholder) {
return $part->getManufacturer() instanceof Manufacturer ? $part->getManufacturer()->getName() : '';
return $part->getManufacturer() instanceof Manufacturer ? htmlspecialchars($part->getManufacturer()->getName()) : '';
}
if ('[[MANUFACTURER_FULL]]' === $placeholder) {
return $part->getManufacturer() instanceof Manufacturer ? $part->getManufacturer()->getFullPath() : '';
return $part->getManufacturer() instanceof Manufacturer ? htmlspecialchars($part->getManufacturer()->getFullPath()) : '';
}
if ('[[FOOTPRINT]]' === $placeholder) {
return $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '';
return $part->getFootprint() instanceof Footprint ? htmlspecialchars($part->getFootprint()->getName()) : '';
}
if ('[[FOOTPRINT_FULL]]' === $placeholder) {
return $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getFullPath() : '';
return $part->getFootprint() instanceof Footprint ? htmlspecialchars($part->getFootprint()->getFullPath()) : '';
}
if ('[[MASS]]' === $placeholder) {
@ -100,15 +100,15 @@ final class PartProvider implements PlaceholderProviderInterface
}
if ('[[MPN]]' === $placeholder) {
return $part->getManufacturerProductNumber();
return htmlspecialchars($part->getManufacturerProductNumber());
}
if ('[[IPN]]' === $placeholder) {
return $part->getIpn() ?? '';
return $part->getIpn() ? htmlspecialchars($part->getIpn()) : '';
}
if ('[[TAGS]]' === $placeholder) {
return $part->getTags();
return htmlspecialchars($part->getTags());
}
if ('[[M_STATUS]]' === $placeholder) {

View file

@ -46,6 +46,7 @@ interface PlaceholderProviderInterface
/**
* Determines the real value of this placeholder.
* If the placeholder is not supported, null must be returned.
* The placeholder provider has to handle HTML escaping, as the output of this function will be used directly in the label template.
*
* @param string $placeholder The placeholder (e.g. "%%PLACEHOLDER%%") that should be replaced
* @param object $label_target The object that is targeted by the label

View file

@ -31,11 +31,11 @@ class StorelocationProvider implements PlaceholderProviderInterface
{
if ($label_target instanceof StorageLocation) {
if ('[[OWNER]]' === $placeholder) {
return $label_target->getOwner() instanceof User ? $label_target->getOwner()->getFullName() : '';
return $label_target->getOwner() instanceof User ? htmlspecialchars($label_target->getOwner()->getFullName()) : '';
}
if ('[[OWNER_USERNAME]]' === $placeholder) {
return $label_target->getOwner() instanceof User ? $label_target->getOwner()->getUsername() : '';
return $label_target->getOwner() instanceof User ? htmlspecialchars($label_target->getOwner()->getUsername()) : '';
}
}

View file

@ -55,13 +55,13 @@ final class StructuralDBElementProvider implements PlaceholderProviderInterface
return strip_tags((string) $label_target->getComment());
}
if ('[[FULL_PATH]]' === $placeholder) {
return $label_target->getFullPath();
return htmlspecialchars($label_target->getFullPath());
}
if ('[[PARENT]]' === $placeholder) {
return $label_target->getParent() instanceof AbstractStructuralDBElement ? $label_target->getParent()->getName() : '';
return $label_target->getParent() instanceof AbstractStructuralDBElement ? htmlspecialchars($label_target->getParent()->getName()) : '';
}
if ('[[PARENT_FULL_PATH]]' === $placeholder) {
return $label_target->getParent() instanceof AbstractStructuralDBElement ? $label_target->getParent()->getFullPath() : '';
return $label_target->getParent() instanceof AbstractStructuralDBElement ? htmlspecialchars($label_target->getParent()->getFullPath()) : '';
}
}

View file

@ -169,8 +169,8 @@ class LogEntryExtraFormatter
$array['log.collection_deleted.deleted'] = sprintf(
'%s: %s (%s)',
$this->elementTypeNameGenerator->getLocalizedTypeLabel($context->getDeletedElementClass()),
$context->getOldName() ?? (string) $context->getDeletedElementID(),
$context->getCollectionName()
htmlspecialchars($context->getOldName() ?? (string) $context->getDeletedElementID()),
htmlspecialchars($context->getCollectionName())
);
}
@ -218,4 +218,4 @@ class LogEntryExtraFormatter
return implode(', ', $output);
}
}
}

View file

@ -0,0 +1,63 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\System;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Checks whether APP_SECRET has been changed from the default value shipped with Part-DB.
*/
final readonly class AppSecretChecker
{
/** Known default/example secrets that must not be used in production. */
public const INSECURE_SECRETS = [
'a03498528f5a5fc089273ec9ae5b2849', // default in .env
'318b5d659e07a0b3f96d9b3a83b254ca', // default in .env.dev
'CHANGE_ME' //example secret used in documentation and error messages
];
public function __construct(
#[Autowire('%kernel.secret%')]
private string $appSecret,
) {
}
/**
* @return bool True if the app secret is one of the known insecure default secrets, false otherwise.
*/
public function isInsecureSecret(): bool
{
return in_array($this->appSecret, self::INSECURE_SECRETS, true);
}
/**
* Generates a new random app secret that can be used to replace the default insecure one.
* @return string
* @throws \Random\RandomException
*/
public function generateSecret(): string
{
//Symfony docs recommend 32 characters for the app secret, which are 16 random bytes when hex-encoded.
return bin2hex(random_bytes(16));
}
}

View file

@ -0,0 +1,45 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\System;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Checks whether the TRUSTED_HOSTS environment variable has been configured.
*/
final readonly class TrustedHostsChecker
{
public function __construct(
#[Autowire('%env(TRUSTED_HOSTS)%')]
private string $trustedHosts,
) {
}
/**
* @return bool True if TRUSTED_HOSTS is not configured (meaning Part-DB accepts requests with any Host header), false otherwise.
*/
public function isTrustedHostsUnconfigured(): bool
{
return trim($this->trustedHosts) === '';
}
}

View file

@ -0,0 +1,96 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\System;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Generates absolute URLs for routes whose host can be trusted, for use in contexts where the host must not
* be attacker-controllable (e.g. links sent out via email).
*
* If no TRUSTED_HOSTS is configured, Symfony does not validate the Host header of the incoming request in
* any way, so it must not be trusted to generate such links (otherwise an attacker could poison them via a
* forged Host header). In that case, the host/scheme/base path are forced to the ones configured via
* DEFAULT_URI instead of the ones from the current HTTP request.
* If TRUSTED_HOSTS is configured, Symfony already rejects requests with a non-matching Host header, so the
* request's host can be trusted and is used as usual (which allows the app to be reachable under multiple
* trusted hostnames).
*/
final class TrustedUrlGenerator
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly TrustedHostsChecker $trustedHostsChecker,
#[Autowire('%partdb.default_uri%')]
private readonly string $defaultUri,
) {
}
/**
* @param array<string, mixed> $parameters
*/
public function generate(string $route, array $parameters = []): string
{
//If TRUSTED_HOSTS is configured, Symfony already validated the request's Host header, so it can be trusted
if (!$this->trustedHostsChecker->isTrustedHostsUnconfigured()) {
return $this->urlGenerator->generate($route, $parameters, UrlGeneratorInterface::ABSOLUTE_URL);
}
$parts = parse_url(rtrim($this->defaultUri, '/'));
$context = $this->urlGenerator->getContext();
//Backup the original context, so we can restore it after generating the URL
$original = [
'host' => $context->getHost(),
'scheme' => $context->getScheme(),
'httpPort' => $context->getHttpPort(),
'httpsPort' => $context->getHttpsPort(),
'baseUrl' => $context->getBaseUrl(),
];
$scheme = $parts['scheme'] ?? 'https';
$context->setScheme($scheme);
$context->setHost($parts['host'] ?? '');
if (isset($parts['port'])) {
if ($scheme === 'https') {
$context->setHttpsPort($parts['port']);
} else {
$context->setHttpPort($parts['port']);
}
}
$context->setBaseUrl($parts['path'] ?? '');
try {
return $this->urlGenerator->generate($route, $parameters, UrlGeneratorInterface::ABSOLUTE_URL);
} finally {
//Restore the original context, so the rest of the request is not affected
$context->setHost($original['host']);
$context->setScheme($original['scheme']);
$context->setHttpPort($original['httpPort']);
$context->setHttpsPort($original['httpsPort']);
$context->setBaseUrl($original['baseUrl']);
}
}
}

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Services\UserSystem;
use App\Entity\UserSystem\User;
use App\Services\System\TrustedUrlGenerator;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
@ -39,7 +40,8 @@ class PasswordResetManager
public function __construct(protected MailerInterface $mailer, protected EntityManagerInterface $em,
protected TranslatorInterface $translator, protected UserPasswordHasherInterface $userPasswordEncoder,
PasswordHasherFactoryInterface $encoderFactory)
PasswordHasherFactoryInterface $encoderFactory,
protected TrustedUrlGenerator $trustedUrlGenerator)
{
$this->passwordEncoder = $encoderFactory->getPasswordHasher(User::class);
}
@ -63,6 +65,9 @@ class PasswordResetManager
$user->setPwResetExpires($expiration_date);
if ($user->getEmail() !== null && $user->getEmail() !== '') {
$reset_url = $this->trustedUrlGenerator->generate('pw_reset_new_pw', ['user' => $user->getName(), 'token' => $unencrypted_token]);
$reset_url_fallback = $this->trustedUrlGenerator->generate('pw_reset_new_pw');
$address = new Address($user->getEmail(), $user->getFullName());
$mail = new TemplatedEmail();
$mail->to($address);
@ -72,6 +77,8 @@ class PasswordResetManager
'expiration_date' => $expiration_date,
'token' => $unencrypted_token,
'user' => $user,
'reset_url' => $reset_url,
'reset_url_fallback' => $reset_url_fallback,
]);
//Send email

View file

@ -35,6 +35,8 @@ class AISettings
{
use SettingsTrait;
public const TIMEOUT_LIMIT = 600;
#[EmbeddedSettings]
public ?McpSettings $mcp = null;
@ -43,4 +45,7 @@ class AISettings
#[EmbeddedSettings]
public ?LMStudioSettings $lmstudio = null;
#[EmbeddedSettings]
public ?OllamaSettings $ollama = null;
}

View file

@ -23,16 +23,17 @@ declare(strict_types=1);
namespace App\Settings\AISettings;
use App\Form\Type\APIKeyType;
use App\Services\AI\AIPlatformSettingsInterface;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Translation\StaticMessage;
use Symfony\Component\Translation\TranslatableMessage as TM;
use Symfony\Component\Validator\Constraints as Assert;
#[Settings(name: 'ai_lmstudio', label: new TM("settings.ai.lmstudio"))]
#[SettingsIcon("fa-robot")]
@ -46,6 +47,14 @@ class LMStudioSettings implements AIPlatformSettingsInterface
envVar: "AI_LMSTUDIO_HOSTURL", envVarMode: EnvVarMode::OVERWRITE)]
public ?string $hostURL = null;
#[SettingsParameter(label: new TM("settings.ai.timeout"),
description: new TM("settings.ai.timeout.help"),
formType: NumberType::class,
formOptions: ["scale" => 0, "attr" => ["min" => 1]],
)]
#[Assert\Range(min: 1, max: AISettings::TIMEOUT_LIMIT)]
public int $timeout = 180;
public function isAIPlatformEnabled(): bool
{
return $this->hostURL !== null && $this->hostURL !== "";

View file

@ -0,0 +1,68 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Settings\AISettings;
use App\Form\Type\APIKeyType;
use App\Services\AI\AIPlatformSettingsInterface;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Translation\StaticMessage;
use Symfony\Component\Translation\TranslatableMessage as TM;
use Symfony\Component\Validator\Constraints as Assert;
#[Settings(name: 'ai_ollama', label: new TM("settings.ai.ollama"))]
#[SettingsIcon("fa-robot")]
class OllamaSettings implements AIPlatformSettingsInterface
{
use SettingsTrait;
#[SettingsParameter(label: new TM("settings.ai.ollama.endpoint"),
formType: UrlType::class,
formOptions: ["attr" => ["placeholder" => new StaticMessage("http://localhost:11434")]],
envVar: "AI_OLLAMA_ENDPOINT", envVarMode: EnvVarMode::OVERWRITE)]
public ?string $endpoint = null;
#[SettingsParameter(label: new TM("settings.ai.ollama.apiKey"),
formType: APIKeyType::class,
envVar: "AI_OLLAMA_API_KEY", envVarMode: EnvVarMode::OVERWRITE)]
public ?string $apiKey = null;
#[SettingsParameter(label: new TM("settings.ai.timeout"),
description: new TM("settings.ai.timeout.help"),
formType: NumberType::class,
formOptions: ["scale" => 0, "attr" => ["min" => 1]]
)]
#[Assert\Range(min: 1, max: AISettings::TIMEOUT_LIMIT)]
public int $timeout = 180;
public function isAIPlatformEnabled(): bool
{
return $this->endpoint !== null && $this->endpoint !== "";
}
}

View file

@ -30,7 +30,9 @@ use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Translation\TranslatableMessage as TM;
use Symfony\Component\Validator\Constraints as Assert;
#[Settings(name: 'ai_openrouter', label: new TM("settings.ai.openrouter"), description: "settings.ai.openrouter.help")]
#[SettingsIcon("fa-robot")]
@ -43,6 +45,14 @@ class OpenRouterSettings implements AIPlatformSettingsInterface
formOptions: ["help_html" => true], envVar: "AI_OPENROUTER_KEY", envVarMode: EnvVarMode::OVERWRITE)]
public ?string $apiKey = null;
#[SettingsParameter(label: new TM("settings.ai.timeout"),
description: new TM("settings.ai.timeout.help"),
formType: NumberType::class,
formOptions: ["scale" => 0, "attr" => ["min" => 1]],
envVar: "int:AI_OPENROUTER_TIMEOUT", envVarMode: EnvVarMode::OVERWRITE)]
#[Assert\Range(min: 1, max: AISettings::TIMEOUT_LIMIT)]
public int $timeout = 90;
public function isAIPlatformEnabled(): bool
{
return $this->apiKey !== null && $this->apiKey !== "";

View file

@ -72,7 +72,7 @@ class CanopySettings
/**
* @var string The domain used internally for the API requests. This is not necessarily the same as the domain shown to the user, which is determined by the keys of the ALLOWED_DOMAINS constant
*/
#[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS, 'translation_domain' => false])]
#[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS, 'choice_translation_domain' => false])]
public string $domain = "DE";
/**