Release 2.47 (#5784)

This commit is contained in:
Kevin Papst
2026-01-25 09:51:22 +01:00
committed by GitHub
parent 6a86afb5fd
commit d429c56687
81 changed files with 2487 additions and 3801 deletions

View File

@@ -508,7 +508,7 @@ final class TranslationCommand extends Command
\sprintf('Missing english translation for key: %s in file %s', $key, $file)
);
}
$unit->target[0] = $translations[$key];
$unit->target[0] = $translations[$key]; // @phpstan-ignore-line
$unit->target['state'] = 'needs-translation';
$foundEmpty = true;
}

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.46.0';
public const VERSION = '2.47.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 24600;
public const VERSION_ID = 24700;
/**
* The software name
*/

View File

@@ -301,7 +301,7 @@ class InvoiceTemplate implements EntityWithMetaFields
}
/**
* @return Collection|MetaTableTypeInterface[]
* @return Collection<int, InvoiceTemplateMeta>
*/
public function getMetaFields(): Collection
{

View File

@@ -376,14 +376,24 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
}
/**
* This method is called from the "edit user preferences" form.
* Therefor it just merges the values for existing preferences and adds new ones.
* But it will NOT remove existing preferences or replace the underlying collection.
*
* @param iterable<UserPreference> $preferences
*/
public function setPreferences(iterable $preferences): User
{
$this->preferences = new ArrayCollection();
foreach ($preferences as $preference) {
$this->addPreference($preference);
if (($name = $preference->getName()) === null) {
continue;
}
$p = $this->getPreference($name);
if ($p === null) {
$this->addPreference($preference);
} else {
$p->setValue($preference->getValue());
}
}
return $this;

View File

@@ -24,7 +24,7 @@ use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TimesheetQuery;
use App\Twig\SecurityPolicy\ExportPolicy;
use App\Twig\SecurityPolicy\StrictPolicy;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
use Symfony\Component\HttpFoundation\Response;
@@ -100,9 +100,12 @@ class HtmlRenderer implements ExportRendererInterface
$summary = $this->calculateSummary($exportItems);
// enable basic security measures
$sandbox = new SandboxExtension(new ExportPolicy());
if (!$this->twig->hasExtension(SandboxExtension::class)) {
$this->twig->addExtension(new SandboxExtension(new StrictPolicy()));
}
$sandbox = $this->twig->getExtension(SandboxExtension::class);
$sandbox->enableSandbox();
$this->twig->addExtension($sandbox);
$content = $this->twig->render($this->getTemplate(), array_merge([
'entries' => $exportItems,

View File

@@ -17,7 +17,7 @@ use App\Pdf\PdfContext;
use App\Pdf\PdfRendererTrait;
use App\Project\ProjectStatisticService;
use App\Repository\Query\TimesheetQuery;
use App\Twig\SecurityPolicy\ExportPolicy;
use App\Twig\SecurityPolicy\StrictPolicy;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
@@ -103,9 +103,12 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
$summary = $this->calculateSummary($exportItems);
// enable basic security measures
$sandbox = new SandboxExtension(new ExportPolicy());
if (!$this->twig->hasExtension(SandboxExtension::class)) {
$this->twig->addExtension(new SandboxExtension(new StrictPolicy()));
}
$sandbox = $this->twig->getExtension(SandboxExtension::class);
$sandbox->enableSandbox();
$this->twig->addExtension($sandbox);
$content = $this->twig->render($this->getTemplate(), array_merge([
'entries' => $exportItems,
@@ -116,6 +119,8 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
'pdfContext' => $context
], $this->getOptions($query)));
$sandbox->disableSandbox();
$pdfOptions = array_merge($context->getOptions(), $this->getPdfOptions());
$content = $this->converter->convertToPdf($content, $pdfOptions);

View File

@@ -30,11 +30,10 @@ final class UserPreferencesCollectionType extends AbstractType
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event): void {
/** @var ArrayCollection<UserPreference> $collection */
/** @var ArrayCollection<int, UserPreference> $collection */
$collection = $event->getData();
$preferences = new ArrayCollection();
foreach ($collection as $collectionItem) {
$collection->removeElement($collectionItem);
if (!($collectionItem instanceof UserPreference)) {
continue;
}
@@ -44,8 +43,9 @@ final class UserPreferencesCollectionType extends AbstractType
continue;
}
$collection->set($collectionItem->getName(), $collectionItem);
$preferences->set($collectionItem->getName(), clone $collectionItem);
}
$event->setData($preferences);
},
// must be a higher priority then the listener in UserPreferenceType
100

View File

@@ -13,7 +13,7 @@ use App\Invoice\InvoiceModel;
use App\Invoice\RendererInterface;
use App\Model\InvoiceDocument;
use App\Twig\LocaleFormatExtensions;
use App\Twig\SecurityPolicy\InvoicePolicy;
use App\Twig\SecurityPolicy\StrictPolicy;
use Symfony\Bridge\Twig\Extension\TranslationExtension;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Twig\Environment;
@@ -64,7 +64,7 @@ abstract class AbstractTwigRenderer implements RendererInterface
}
if (!$twig->hasExtension(SandboxExtension::class)) {
$twig->addExtension(new SandboxExtension(new InvoicePolicy()));
$twig->addExtension(new SandboxExtension(new StrictPolicy()));
}
$sandbox = $twig->getExtension(SandboxExtension::class);

View File

@@ -1,44 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Twig\SecurityPolicy;
use Twig\Sandbox\SecurityPolicyInterface;
final class ChainPolicy implements SecurityPolicyInterface
{
/** @var array<SecurityPolicyInterface> */
private array $policies = [];
public function addPolicy(SecurityPolicyInterface $policy): void
{
$this->policies[] = $policy;
}
public function checkSecurity($tags, $filters, $functions): void
{
foreach ($this->policies as $policy) {
$policy->checkSecurity($tags, $filters, $functions);
}
}
public function checkMethodAllowed($obj, $method): void
{
foreach ($this->policies as $policy) {
$policy->checkMethodAllowed($obj, $method);
}
}
public function checkPropertyAllowed($obj, $property): void
{
foreach ($this->policies as $policy) {
$policy->checkPropertyAllowed($obj, $property);
}
}
}

View File

@@ -1,80 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Twig\SecurityPolicy;
use App\Entity\User;
use App\Pdf\PdfContext;
use Symfony\Bridge\Twig\AppVariable;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ServerBag;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityPolicyInterface;
/**
* The Twig environment needs the sandbox extension, which itself needs a policy to start working.
*/
final class DefaultPolicy implements SecurityPolicyInterface
{
public function checkSecurity($tags, $filters, $functions): void
{
}
public function checkMethodAllowed($obj, $method): void
{
if ($obj instanceof ServerBag) {
throw new SecurityNotAllowedMethodError('Tried to access server environment', ServerBag::class, $method);
}
if ($obj instanceof SessionInterface) {
throw new SecurityNotAllowedMethodError('Tried to access session', SessionInterface::class, $method);
}
$lcm = strtolower($method);
if ($obj instanceof PdfContext) {
if ($lcm !== 'setoption') {
throw new SecurityNotAllowedMethodError('Tried to access forbidden method on PdfContext', PdfContext::class, $method);
}
return;
}
if (!str_starts_with($lcm, 'has') && !str_starts_with($lcm, 'is') && !str_starts_with($lcm, 'get') && $lcm !== '__tostring') {
throw new SecurityNotAllowedMethodError('Tried to access non-read method', $obj::class, $method);
}
if ($obj instanceof Request) {
if (!str_starts_with($lcm, 'get')) {
throw new SecurityNotAllowedMethodError('Tried to call setter() of app variable', AppVariable::class, $method);
}
return;
}
if ($obj instanceof AppVariable) {
if (!\in_array($lcm, ['getrequest', 'getuser', 'getlocale'], true)) {
throw new SecurityNotAllowedMethodError('Tried to access forbidden app variable method', User::class, $method);
}
return;
}
if ($obj instanceof User) {
if (\in_array($lcm, ['getpassword', 'gettotpsecret', 'getplainpassword', 'getconfirmationtoken', 'gettotpauthenticationconfiguration'], true)) {
throw new SecurityNotAllowedMethodError('Tried to access user secrets', User::class, $method);
}
}
}
public function checkPropertyAllowed($obj, $property): void
{
}
}

View File

@@ -1,41 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Twig\SecurityPolicy;
use Twig\Sandbox\SecurityPolicyInterface;
/**
* Represents the security policy for custom Twig export templates.
*/
final class ExportPolicy implements SecurityPolicyInterface
{
private ChainPolicy $policy;
public function __construct()
{
$this->policy = new ChainPolicy();
$this->policy->addPolicy(new DefaultPolicy());
}
public function checkSecurity($tags, $filters, $functions): void
{
$this->policy->checkSecurity($tags, $filters, $functions);
}
public function checkMethodAllowed($obj, $method): void
{
$this->policy->checkMethodAllowed($obj, $method);
}
public function checkPropertyAllowed($obj, $property): void
{
$this->policy->checkPropertyAllowed($obj, $property);
}
}

View File

@@ -1,228 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Twig\SecurityPolicy;
use App\Invoice\InvoiceModel;
use App\Pdf\PdfContext;
use Symfony\Component\String\UnicodeString;
use Twig\Markup;
use Twig\Sandbox\SecurityPolicy;
use Twig\Sandbox\SecurityPolicyInterface;
use Twig\Template;
/**
* Represents the security policy for custom Twig invoice templates.
*/
final class InvoicePolicy implements SecurityPolicyInterface
{
private SecurityPolicyInterface $default;
private SecurityPolicyInterface $security;
public function __construct()
{
$this->default = new DefaultPolicy();
$this->security = new SecurityPolicy(
['block', 'if', 'for', 'set', 'extends', 'import'],
[
// =================================================================
// vendor/twig/twig/src/Extension/CoreExtension.php
// formatting filters
'date',
'date_modify',
'format',
'replace',
'number_format',
'abs',
'round',
// encoding
'url_encode',
'json_encode',
'convert_encoding',
// string filters
'title',
'capitalize',
'upper',
'lower',
'striptags',
'trim',
'nl2br',
'spaceless',
// array helpers
'join',
'split',
'sort',
'merge',
'batch',
'column',
'filter',
'map',
'reduce',
// string/array filters
'reverse',
'length',
'slice',
'first',
'last',
// iteration and runtime
'default',
'keys',
// =================================================================
// vendor/twig/twig/src/Extension/EscaperExtension.php
'escape',
'e',
'raw',
// =================================================================
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
'trans',
// =================================================================
// vendor/twig/string-extra/StringExtension.php
'u',
'slug',
// =================================================================
// vendor/twig/intl-extra/IntlExtension.php
'country_name',
'currency_name',
'currency_symbol',
'language_name',
'locale_name',
'timezone_name',
'format_currency',
'format_number',
'format_decimal_number',
'format_currency_number',
'format_percent_number',
'format_scientific_number',
'format_spellout_number',
'format_ordinal_number',
'format_duration_number',
'format_datetime',
'format_date',
'format_time',
// =================================================================
// src/Twig/LocaleFormatExtensions.php
'month_name',
'day_name',
'date_short',
'date_time',
'date_full',
'date_format',
'date_weekday',
'time',
'duration',
'duration_decimal',
'money',
'amount',
// =================================================================
// src/Twig/RuntimeExtensions.php
'md2html',
'desc2html',
'comment2html',
'comment1line',
// =================================================================
// src/Twig/Extensions.php
'multiline_indent',
'color',
'font_contrast',
'default_color',
'nl2str',
],
[
PdfContext::class => ['setoption'],
InvoiceModel::class => ['toarray'],
],
[], // properties
[
// =================================================================
// vendor/twig/twig/src/Extension/CoreExtension.php
'max',
'min',
'range',
'constant',
'cycle',
'random',
'date',
'asset',
'range',
// =================================================================
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
't',
// =================================================================
// vendor/symfony/webpack-encore-bundle/src/Twig/EntryFilesTwigExtension.php
'encore_entry_css_source',
// =================================================================
// vendor/symfony/twig-bridge/Extension/AssetExtension.php
'asset',
// =================================================================
// vendor/symfony/twig-bridge/Extension/SecurityExtension.php
'is_granted',
// =================================================================
// Twig/RuntimeExtensions.php
'qr_code_data_uri',
// =================================================================
// Twig/Configuration.php
'config',
// =================================================================
// Twig/LocaleFormatExtensions.php
'create_date',
'month_names',
'locale_format',
]
);
}
public function checkSecurity($tags, $filters, $functions): void
{
$this->default->checkSecurity($tags, $filters, $functions);
$this->security->checkSecurity($tags, $filters, $functions);
}
public function checkMethodAllowed($obj, $method): void
{
if ($obj instanceof Template || $obj instanceof Markup || $obj instanceof UnicodeString) { // @phpstan-ignore instanceof.internalClass
return;
}
$this->default->checkMethodAllowed($obj, $method);
$lm = strtolower($method);
if (str_starts_with($lm, 'get') || str_starts_with($lm, 'is') || str_starts_with($lm, 'has') || $lm === '__tostring') {
return;
}
$this->security->checkMethodAllowed($obj, $method);
}
public function checkPropertyAllowed($obj, $property): void
{
$this->default->checkPropertyAllowed($obj, $property);
$this->security->checkPropertyAllowed($obj, $property);
}
}

View File

@@ -0,0 +1,165 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Twig\SecurityPolicy;
use App\Entity\MetaTableTypeInterface;
use App\Entity\User;
use App\Pdf\PdfContext;
use Symfony\Bridge\Twig\AppVariable;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ServerBag;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\String\UnicodeString;
use Twig\Sandbox\SecurityNotAllowedFilterError;
use Twig\Sandbox\SecurityNotAllowedFunctionError;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityNotAllowedTagError;
use Twig\Sandbox\SecurityPolicyInterface;
/**
* The Twig environment needs the sandbox extension, which itself needs a policy to start working.
*/
final class StrictPolicy implements SecurityPolicyInterface
{
/** @var string[] */
private array $allowedTags = ['block', 'if', 'for', 'set', 'macro', 'import', 'extends', 'from'];
/** @var string[] */
private array $allowedFunctions = [
// vendor/twig/twig/src/Extension/CoreExtension.php
'max', 'min', 'range', 'constant', 'cycle', 'random', 'date',
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
't',
// vendor/symfony/webpack-encore-bundle/src/Twig/EntryFilesTwigExtension.php
'encore_entry_css_source', 'encore_entry_link_tags', 'encore_entry_script_tags',
// vendor/symfony/twig-bridge/Extension/SecurityExtension.php
'is_granted',
// Twig/RuntimeExtensions.php
'qr_code_data_uri',
// Twig/Configuration.php
'config',
// Twig/LocaleFormatExtensions.php
'create_date', 'month_names', 'locale_format',
// Twig/Extensions.php
'class_name'
];
/** @var string[] */
private array $allowedFilters = [
// vendor/twig/twig/src/Extension/CoreExtension.php
// formatting filters
'date', 'date_modify', 'format', 'replace', 'number_format', 'abs', 'round',
// encoding
'url_encode', 'json_encode',
// string filters
'title', 'capitalize', 'upper', 'lower', 'striptags', 'trim', 'nl2br', 'spaceless',
// array helpers
'join', 'split', 'sort', 'merge', 'column', 'filter', 'map',
// string/array filters
'reverse', 'length', 'slice', 'first', 'last',
// iteration and runtime
'default', 'keys',
// vendor/twig/twig/src/Extension/EscaperExtension.php
'escape', 'e', 'raw',
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
'trans',
// vendor/twig/string-extra/StringExtension.php
'u', 'slug',
// vendor/twig/intl-extra/IntlExtension.php
'country_name', 'currency_name', 'currency_symbol', 'language_name', 'locale_name', 'timezone_name',
'format_currency', 'format_number', 'format_decimal_number', 'format_currency_number',
'format_duration_number', 'format_datetime', 'format_date', 'format_time',
// src/Twig/LocaleFormatExtensions.php
'month_name', 'day_name', 'date_short', 'date_time', 'date_full', 'date_format',
'date_weekday', 'time', 'duration', 'duration_decimal', 'money', 'amount',
// src/Twig/RuntimeExtensions.php
'md2html', 'desc2html', 'comment2html', 'comment1line',
// src/Twig/Extensions.php
'multiline_indent', 'color', 'nl2str'
];
public function checkSecurity($tags, $filters, $functions): void
{
foreach ($tags as $tag) {
if (!\in_array($tag, $this->allowedTags, true)) {
throw new SecurityNotAllowedTagError(\sprintf('Tag "%s" is not allowed.', $tag), $tag);
}
}
foreach ($filters as $filter) {
if (!\in_array($filter, $this->allowedFilters, true)) {
throw new SecurityNotAllowedFilterError(\sprintf('Filter "%s" is not allowed.', $filter), $filter);
}
}
foreach ($functions as $function) {
if (!\in_array($function, $this->allowedFunctions, true)) {
throw new SecurityNotAllowedFunctionError(\sprintf('Function "%s" is not allowed.', $function), $function);
}
}
}
public function checkMethodAllowed($obj, $method): void
{
if ($obj instanceof UnicodeString) {
return;
}
if ($obj instanceof ServerBag) {
throw new SecurityNotAllowedMethodError('Tried to access server environment', ServerBag::class, $method);
}
if ($obj instanceof SessionInterface) {
throw new SecurityNotAllowedMethodError('Tried to access session', SessionInterface::class, $method);
}
$lcm = strtolower($method);
if ($obj instanceof PdfContext) {
if ($lcm !== 'setoption') {
throw new SecurityNotAllowedMethodError('Tried to access forbidden method on PdfContext', PdfContext::class, $method);
}
return;
}
if ($obj instanceof MetaTableTypeInterface && $lcm === 'merge') {
return;
}
if ($obj instanceof Request) {
if (!str_starts_with($lcm, 'get')) {
throw new SecurityNotAllowedMethodError('Tried to call setter() of app variable', AppVariable::class, $method);
}
return;
}
if ($obj instanceof AppVariable) {
if (!\in_array($lcm, ['getrequest', 'getuser', 'getlocale'], true)) {
throw new SecurityNotAllowedMethodError('Tried to access forbidden app variable method', User::class, $method);
}
return;
}
if (!str_starts_with($lcm, 'get') && !str_starts_with($lcm, 'has') && !str_starts_with($lcm, 'is') && $lcm !== '__tostring') {
throw new SecurityNotAllowedMethodError('Tried to access non-read method', $obj::class, $method);
}
if ($obj instanceof User) {
if (\in_array($lcm, ['getpassword', 'gettotpsecret', 'getplainpassword', 'getconfirmationtoken', 'gettotpauthenticationconfiguration'], true)) {
throw new SecurityNotAllowedMethodError('Tried to access user secrets', User::class, $method);
}
}
}
public function checkPropertyAllowed($obj, $property): void
{
}
}

View File

@@ -21,10 +21,7 @@ final class ReleaseVersion
/**
* Get all releases from GitHub.
*
* @throws \Exception
* @return array|null
* @return array<string, array{'version': string, 'date': \DateTimeInterface, 'url': string, 'download': string, 'content': string}>
* @return array
* @return array<string, array{'version': non-empty-string, 'date': \DateTimeInterface, 'url': non-empty-string, 'download': non-empty-string, 'content': string}>
*/
private function getReleasesFromGithub(): array
{
@@ -41,19 +38,24 @@ final class ReleaseVersion
$context = stream_context_create($opts);
$releases = file_get_contents('https://api.github.com/repos/' . Constants::GITHUB_REPO . '/releases', false, $context);
$releases = json_decode($releases);
if (!isset($releases[0])) {
throw new \Exception('API error - no release found at GitHub repository: ' . Constants::GITHUB_REPO);
if ($releases === false) {
throw new \Exception('Could not load releases from GitHub repository: ' . Constants::GITHUB_REPO);
}
/** @var array<string, array{url: non-empty-string, html_url: non-empty-string, tag_name: non-empty-string, name: non-empty-string, draft: bool, immutable: bool, prerelease: bool, created_at: non-empty-string, updated_at: non-empty-string, published_at: non-empty-string, zipball_url: non-empty-string, body: string}> $releases */
$releases = json_decode($releases, true);
if ($releases === false) {
throw new \Exception('Failed parsing release found at GitHub repository: ' . Constants::GITHUB_REPO);
}
$parsed = [];
foreach ($releases as $release) {
if ($release->draft || $release->prerelease) {
if ($release['draft'] || $release['prerelease']) {
continue;
}
try {
$normalized = $versionParser->normalize($release->tag_name);
$normalized = $versionParser->normalize($release['tag_name']);
} catch (\UnexpectedValueException $e) {
continue;
}
@@ -62,19 +64,19 @@ final class ReleaseVersion
continue;
}
$date = $release->published_at;
$date = $release['published_at'];
try {
$date = new \DateTimeImmutable($date);
} catch (\Exception $ex) {
// can be ignored, we return a string
continue;
}
$parsed[$normalized] = [
'version' => $release->tag_name,
'version' => $release['tag_name'],
'date' => $date,
'url' => $release->html_url,
'download' => $release->zipball_url,
'content' => $release->body,
'url' => $release['html_url'],
'download' => $release['zipball_url'],
'content' => $release['body'],
];
}
$versions = Semver::rsort(array_keys($parsed));