mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-07-27 03:31:35 +00:00
Merge branch 'master' into mcp
This commit is contained in:
commit
7c0b47d8f8
173 changed files with 27011 additions and 7959 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class EntityColumn extends AbstractColumn
|
|||
);
|
||||
}
|
||||
|
||||
return sprintf('<i>%s</i>', $value);
|
||||
return sprintf('<i>%s</i>', htmlspecialchars($value));
|
||||
}
|
||||
|
||||
return '';
|
||||
|
|
|
|||
37
src/DataTables/Column/HTMLColumn.php
Normal file
37
src/DataTables/Column/HTMLColumn.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
])
|
||||
;
|
||||
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ trait AdvancedPropertyTrait
|
|||
*/
|
||||
#[ORM\Embedded(class: InfoProviderReference::class, columnPrefix: 'provider_reference_')]
|
||||
#[Groups(['full', 'part:read'])]
|
||||
#[Assert\Valid()]
|
||||
protected InfoProviderReference $providerReference;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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"])]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'])]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
79
src/Form/Extension/DateTimeModelTimezoneExtension.php
Normal file
79
src/Form/Extension/DateTimeModelTimezoneExtension.php
Normal 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
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
113
src/Form/InfoProviderSystem/InfoProviderReferenceType.php
Normal file
113
src/Form/InfoProviderSystem/InfoProviderReferenceType.php
Normal 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);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -233,7 +233,6 @@ trait PKImportHelperTrait
|
|||
|
||||
$reflectionClass = new \ReflectionClass($entity);
|
||||
$property = $reflectionClass->getProperty('addedDate');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue($entity, $date);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ final class DTOJsonSchemaConverter
|
|||
public function getJSONSchema(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'clock',
|
||||
'name' => 'part_detail',
|
||||
'strict' => true,
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()) : '';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()) : '';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/Services/System/AppSecretChecker.php
Normal file
63
src/Services/System/AppSecretChecker.php
Normal 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));
|
||||
}
|
||||
}
|
||||
45
src/Services/System/TrustedHostsChecker.php
Normal file
45
src/Services/System/TrustedHostsChecker.php
Normal 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) === '';
|
||||
}
|
||||
}
|
||||
96
src/Services/System/TrustedUrlGenerator.php
Normal file
96
src/Services/System/TrustedUrlGenerator.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 !== "";
|
||||
|
|
|
|||
68
src/Settings/AISettings/OllamaSettings.php
Normal file
68
src/Settings/AISettings/OllamaSettings.php
Normal 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 !== "";
|
||||
}
|
||||
}
|
||||
|
|
@ -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 !== "";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue