Avoid using render in datatables, as it require escaping

This commit also fixes an XSS vulnerability in IPN project bom
This commit is contained in:
Jan Böhmer 2026-06-14 22:16:00 +02:00
parent 11b41ee66a
commit dfbdac7688
7 changed files with 150 additions and 88 deletions

View file

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

View file

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

View file

@ -22,9 +22,9 @@ declare(strict_types=1);
*/ */
namespace App\DataTables; namespace App\DataTables;
use App\DataTables\Column\HTMLColumn;
use App\DataTables\Column\RowClassColumn; use App\DataTables\Column\RowClassColumn;
use Omines\DataTablesBundle\Adapter\ArrayAdapter; use Omines\DataTablesBundle\Adapter\ArrayAdapter;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable; use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableFactory; use Omines\DataTablesBundle\DataTableFactory;
use Omines\DataTablesBundle\DataTableTypeInterface; use Omines\DataTablesBundle\DataTableTypeInterface;
@ -32,7 +32,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
class ErrorDataTable implements DataTableTypeInterface final readonly class ErrorDataTable implements DataTableTypeInterface
{ {
public function configureOptions(OptionsResolver $optionsResolver): void public function configureOptions(OptionsResolver $optionsResolver): void
{ {
@ -49,6 +49,11 @@ class ErrorDataTable implements DataTableTypeInterface
public function configure(DataTable $dataTable, array $options): void 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(); $optionsResolver = new OptionsResolver();
$this->configureOptions($optionsResolver); $this->configureOptions($optionsResolver);
$options = $optionsResolver->resolve($options); $options = $optionsResolver->resolve($options);
@ -58,9 +63,9 @@ class ErrorDataTable implements DataTableTypeInterface
'render' => fn($value, $context): string => 'table-warning', 'render' => fn($value, $context): string => 'table-warning',
]) ])
->add('error', TextColumn::class, [ ->add('error', HTMLColumn::class, [
'label' => 'error_table.error', 'label' => 'error_table.error',
'render' => fn($value, $context): string => '<i class="fa-solid fa-triangle-exclamation fa-fw"></i> ' . $value, 'data' => fn($context, $value): string => '<i class="fa-solid fa-triangle-exclamation fa-fw"></i> ' . htmlspecialchars((string) $value),
]) ])
; ;

View file

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

View file

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

View file

@ -25,6 +25,7 @@ namespace App\DataTables;
use App\DataTables\Adapters\TwoStepORMAdapter; use App\DataTables\Adapters\TwoStepORMAdapter;
use App\DataTables\Column\EntityColumn; use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\EnumColumn; use App\DataTables\Column\EnumColumn;
use App\DataTables\Column\HTMLColumn;
use App\DataTables\Column\IconLinkColumn; use App\DataTables\Column\IconLinkColumn;
use App\DataTables\Column\LocaleDateTimeColumn; use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn; use App\DataTables\Column\MarkdownColumn;
@ -58,7 +59,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface; 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"]]; 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! * 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 $this->csh
//Color the table rows depending on the review and favorite status //Color the table rows depending on the review and favorite status
->add('row_color', RowClassColumn::class, [ ->add('row_color', RowClassColumn::class, [
@ -109,23 +115,23 @@ final class PartsDataTable implements DataTableTypeInterface
}, },
], visibility_configurable: false) ], visibility_configurable: false)
->add('select', SelectColumn::class, visibility_configurable: false) ->add('select', SelectColumn::class, visibility_configurable: false)
->add('picture', TextColumn::class, [ ->add('picture', HTMLColumn::class, [
'label' => '', 'label' => '',
'className' => 'no-colvis', 'className' => 'no-colvis',
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderPicture($context), 'data' => fn(Part $context) => $this->partDataTableHelper->renderPicture($context),
], visibility_configurable: false) ], visibility_configurable: false)
->add('name', TextColumn::class, [ ->add('name', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.name'), '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)' 'orderField' => 'NATSORT(part.name)'
]) ])
->add('si_value', TextColumn::class, [ ->add('si_value', TextColumn::class, [
'label' => $this->translator->trans('part.table.si_value'), 'label' => $this->translator->trans('part.table.si_value'),
'render' => function ($value, Part $context): string { 'data' => function (Part $context): string {
$siValue = SiValueSort::sqliteSiValue($context->getName()); $siValue = SiValueSort::sqliteSiValue($context->getName());
if ($siValue !== null) { if ($siValue !== null) {
//Output it as scientific number with a big E //Output it as scientific number with a big E
return htmlspecialchars(sprintf('%G', $siValue)); return sprintf('%G', $siValue);
} }
return ''; return '';
}, },
@ -156,38 +162,38 @@ final class PartsDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('part.table.manufacturer'), 'label' => $this->translator->trans('part.table.manufacturer'),
'orderField' => 'NATSORT(_manufacturer.name)' 'orderField' => 'NATSORT(_manufacturer.name)'
]) ])
->add('storelocation', TextColumn::class, [ ->add('storelocation', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.storeLocations'), '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 //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))', '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') ], alias: 'storage_location')
->add('amount', TextColumn::class, [ ->add('amount', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.amount'), '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' 'orderField' => 'amountSum'
]) ])
->add('minamount', TextColumn::class, [ ->add('minamount', TextColumn::class, [
'label' => $this->translator->trans('part.table.minamount'), '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, $value,
$context->getPartUnit() $context->getPartUnit()
)), ),
]) ])
->add('partUnit', TextColumn::class, [ ->add('partUnit', TextColumn::class, [
'label' => $this->translator->trans('part.table.partUnit'), 'label' => $this->translator->trans('part.table.partUnit'),
'orderField' => 'NATSORT(_partUnit.name)', 'orderField' => 'NATSORT(_partUnit.name)',
'render' => function ($value, Part $context): string { 'data' => function (Part $context): string {
$partUnit = $context->getPartUnit(); $partUnit = $context->getPartUnit();
if ($partUnit === null) { if ($partUnit === null) {
return ''; return '';
} }
$tmp = htmlspecialchars($partUnit->getName()); $tmp = $partUnit->getName();
if ($partUnit->getUnit()) { if ($partUnit->getUnit()) {
$tmp .= ' (' . htmlspecialchars($partUnit->getUnit()) . ')'; $tmp .= ' (' . $partUnit->getUnit() . ')';
} }
return $tmp; return $tmp;
} }
@ -195,14 +201,14 @@ final class PartsDataTable implements DataTableTypeInterface
->add('partCustomState', TextColumn::class, [ ->add('partCustomState', TextColumn::class, [
'label' => $this->translator->trans('part.table.partCustomState'), 'label' => $this->translator->trans('part.table.partCustomState'),
'orderField' => 'NATSORT(_partCustomState.name)', 'orderField' => 'NATSORT(_partCustomState.name)',
'render' => function($value, Part $context): string { 'data' => function(Part $context): string {
$partCustomState = $context->getPartCustomState(); $partCustomState = $context->getPartCustomState();
if ($partCustomState === null) { if ($partCustomState === null) {
return ''; return '';
} }
return htmlspecialchars($partCustomState->getName()); return $partCustomState->getName();
} }
]) ])
->add('addedDate', LocaleDateTimeColumn::class, [ ->add('addedDate', LocaleDateTimeColumn::class, [
@ -248,25 +254,25 @@ final class PartsDataTable implements DataTableTypeInterface
]) ])
->add('eda_reference', TextColumn::class, [ ->add('eda_reference', TextColumn::class, [
'label' => $this->translator->trans('part.table.eda_reference'), '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)' 'orderField' => 'NATSORT(part.eda_info.reference_prefix)'
]) ])
->add('eda_value', TextColumn::class, [ ->add('eda_value', TextColumn::class, [
'label' => $this->translator->trans('part.table.eda_value'), '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)' 'orderField' => 'NATSORT(part.eda_info.value)'
]) ])
->add('eda_status', TextColumn::class, [ ->add('eda_status', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.eda_status'), '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', '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 //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)) { 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'), 'label' => $this->translator->trans('project.labelp'),
'render' => function ($value, Part $context): string { 'data' => function (Part $context): string {
//Only show the first 5 projects names //Only show the first 5 projects names
$projects = $context->getProjects(); $projects = $context->getProjects();
$tmp = ""; $tmp = "";
@ -286,7 +292,7 @@ final class PartsDataTable implements DataTableTypeInterface
} }
return $tmp; return $tmp;
} },
]); ]);
} }

View file

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