diff --git a/.dockerignore b/.dockerignore
index 56d58bef..c9d2a020 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -46,7 +46,7 @@ var/templates/*
nbproject/
# Dev-only configs
-test/
+tests/
.php-cs-fixer.dist.php
php-cs-fixer.sh
phpstan.sh
@@ -57,6 +57,9 @@ eslint.config.mjs
.codecov.yml
phpunit.xml
phpunit.xml.dist
+package.json
+pnpm-lock.yaml
+webpack.config.js
# Docker and compose (not needed inside the image; .docker/ is copied explicitly)
docker-compose.yml
diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml
index 509aa719..6b4794fa 100644
--- a/.github/release-drafter.yml
+++ b/.github/release-drafter.yml
@@ -4,26 +4,37 @@ exclude-labels:
- 'duplicate'
- 'invalid'
- 'wontfix'
- - 'release'
exclude-contributors:
- 'dependabot'
- 'weblate'
change-template: '- $TITLE (#$NUMBER)'
change-title-escapes: '\<*_&`#@'
+categories:
+ - title: "Features"
+ semver-increment: minor
+ when:
+ labels:
+ - "feature request"
+ - "enhancement"
+ - "technical debt"
+ - "translation"
+ - "release"
+ - title: "Bug Fixes"
+ when:
+ labels:
+ - "fix"
+ - "bugfix"
+ - "bug"
+ - title: "🚨 Security"
+ when:
+ label: "security"
version-resolver:
- minor:
- labels:
- - 'feature request'
- - 'release'
- patch:
- labels:
- - 'technical debt'
- - 'bug'
- - 'translation'
- default: patch
+ default: minor
template: |
**Compatible with PHP 8.2 to 8.5**
$CHANGES
+ You can read more about all security reports [here](https://www.kimai.org/documentation/bughunter.html#published-vulnerabilities) or grab this [RSS feed](https://www.kimai.org/security.xml) to get notified about new published advisories.
+
Involved in this release: $CONTRIBUTORS
diff --git a/.github/renovate-config.js b/.github/renovate-config.js
new file mode 100644
index 00000000..4d585250
--- /dev/null
+++ b/.github/renovate-config.js
@@ -0,0 +1,7 @@
+module.exports = {
+ branchPrefix: 'renovate/',
+ username: 'renovate-release',
+ gitAuthor: 'Renovate Bot ',
+ onboarding: false,
+ platform: 'github'
+};
\ No newline at end of file
diff --git a/.github/workflows/linting.yaml b/.github/workflows/linting.yaml
index 35e0e66e..721fa19d 100644
--- a/.github/workflows/linting.yaml
+++ b/.github/workflows/linting.yaml
@@ -72,5 +72,8 @@ jobs:
- name: Lint codebase
run: composer linting
+ - name: Check for published security advisories
+ run: composer audit --abandoned=report
+
- name: Check for security issues in packages
run: symfony security:check
diff --git a/.github/workflows/renovate.yaml b/.github/workflows/renovate.yaml
new file mode 100644
index 00000000..1c985da5
--- /dev/null
+++ b/.github/workflows/renovate.yaml
@@ -0,0 +1,23 @@
+name: Renovate
+
+permissions: {}
+
+on:
+ schedule:
+ - cron: '17 3 * * *'
+ workflow_dispatch:
+
+jobs:
+ renovate:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Clone Kimai
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Run Renovate
+ uses: renovatebot/github-action@693b9ef15eec82123529a37c782242f091365961 # v46.1.14
+ with:
+ configurationFile: .github/renovate-config.js
+ token: ${{ secrets.RENOVATE_TOKEN }}
diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml
index 42ab458e..c5b060fa 100644
--- a/.github/workflows/testing.yaml
+++ b/.github/workflows/testing.yaml
@@ -96,7 +96,7 @@ jobs:
- name: Upload code coverage
if: matrix.php == '8.5'
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
diff --git a/composer.json b/composer.json
index 733f91bf..6056f0dd 100644
--- a/composer.json
+++ b/composer.json
@@ -30,7 +30,6 @@
"doctrine/doctrine-bundle": "^2.7",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^2.8",
- "easybill/zugferd-php": "^2.1",
"endroid/qr-code": "^4.8",
"erusev/parsedown": "^1.6",
"friendsofsymfony/rest-bundle": "^3.0",
diff --git a/composer.lock b/composer.lock
index 006b1b64..123a8e02 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "fa13e2255a6ecd636351e12bf7b69792",
+ "content-hash": "3a36f355f322598267719beb6a7c22e0",
"packages": [
{
"name": "azuyalabs/yasumi",
@@ -1661,70 +1661,6 @@
},
"time": "2026-02-08T16:21:46+00:00"
},
- {
- "name": "easybill/zugferd-php",
- "version": "v2.3.1",
- "source": {
- "type": "git",
- "url": "https://github.com/easybill/zugferd-php.git",
- "reference": "fba2a504feb58ae2b3a19da5a0c051ce8fe13895"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/easybill/zugferd-php/zipball/fba2a504feb58ae2b3a19da5a0c051ce8fe13895",
- "reference": "fba2a504feb58ae2b3a19da5a0c051ce8fe13895",
- "shasum": ""
- },
- "require": {
- "ext-dom": "*",
- "jms/serializer": "^3.16",
- "php": "^8.2"
- },
- "require-dev": {
- "ext-libxml": "*",
- "friendsofphp/php-cs-fixer": "^3.16",
- "phpstan/phpstan": "^1.10",
- "phpstan/phpstan-strict-rules": "^1.5",
- "phpunit/phpunit": "^9.3",
- "rector/rector": "^1.0",
- "symfony/finder": "^5.4"
- },
- "suggest": {
- "ext-libxml": "Needed for XSD validation"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Easybill\\ZUGFeRD\\": "src/zugferd10",
- "Easybill\\ZUGFeRD2\\": "src/zugferd2"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Patrick Romowicz",
- "email": "patrick@romowicz.de"
- },
- {
- "name": "Jan Nöhles",
- "email": "jan.noehles@easybill.de"
- }
- ],
- "description": "ZUGFeRD PHP SDK (Factur-X, XRechnung) - Convert PHP Objects to XML and back.",
- "keywords": [
- "ZUGFeRD",
- "factur-x",
- "xrechnung"
- ],
- "support": {
- "issues": "https://github.com/easybill/zugferd-php/issues",
- "source": "https://github.com/easybill/zugferd-php/tree/v2.3.1"
- },
- "time": "2025-09-13T13:57:46+00:00"
- },
{
"name": "egulias/email-validator",
"version": "4.0.4",
@@ -4318,16 +4254,16 @@
},
{
"name": "phpoffice/phpspreadsheet",
- "version": "2.4.5",
+ "version": "2.4.6",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
- "reference": "ec7815be350e03df90f3e2ace92653fa6cb4327c"
+ "reference": "0bbef382b7d9c1dbda10c8113d564ff9159a7e79"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/ec7815be350e03df90f3e2ace92653fa6cb4327c",
- "reference": "ec7815be350e03df90f3e2ace92653fa6cb4327c",
+ "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/0bbef382b7d9c1dbda10c8113d564ff9159a7e79",
+ "reference": "0bbef382b7d9c1dbda10c8113d564ff9159a7e79",
"shasum": ""
},
"require": {
@@ -4418,9 +4354,9 @@
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
- "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/2.4.5"
+ "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/2.4.6"
},
- "time": "2026-04-19T05:48:49+00:00"
+ "time": "2026-06-07T02:31:12+00:00"
},
{
"name": "phpoffice/phpword",
@@ -5374,16 +5310,16 @@
},
{
"name": "spomky-labs/otphp",
- "version": "11.4.3",
+ "version": "11.5.0",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/otphp.git",
- "reference": "46663316285bf1f001a31584213b327494b00ae7"
+ "reference": "877683d6352b80cdc7020fd43a725629c2524435"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/46663316285bf1f001a31584213b327494b00ae7",
- "reference": "46663316285bf1f001a31584213b327494b00ae7",
+ "url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/877683d6352b80cdc7020fd43a725629c2524435",
+ "reference": "877683d6352b80cdc7020fd43a725629c2524435",
"shasum": ""
},
"require": {
@@ -5428,7 +5364,7 @@
],
"support": {
"issues": "https://github.com/Spomky-Labs/otphp/issues",
- "source": "https://github.com/Spomky-Labs/otphp/tree/11.4.3"
+ "source": "https://github.com/Spomky-Labs/otphp/tree/11.5.0"
},
"funding": [
{
@@ -5440,7 +5376,7 @@
"type": "patreon"
}
],
- "time": "2026-05-31T12:42:43+00:00"
+ "time": "2026-06-06T23:41:24+00:00"
},
{
"name": "symfony/asset",
@@ -11681,16 +11617,16 @@
},
{
"name": "friendsofphp/php-cs-fixer",
- "version": "v3.95.3",
+ "version": "v3.95.4",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
- "reference": "3d681493acc0e93283481b1c63c263737df78687"
+ "reference": "3f8f68856837a77e1f1d870354eca3c8747f2f72"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3d681493acc0e93283481b1c63c263737df78687",
- "reference": "3d681493acc0e93283481b1c63c263737df78687",
+ "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3f8f68856837a77e1f1d870354eca3c8747f2f72",
+ "reference": "3f8f68856837a77e1f1d870354eca3c8747f2f72",
"shasum": ""
},
"require": {
@@ -11774,7 +11710,7 @@
],
"support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
- "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.3"
+ "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.4"
},
"funding": [
{
@@ -11782,7 +11718,7 @@
"type": "github"
}
],
- "time": "2026-05-29T20:35:26+00:00"
+ "time": "2026-06-03T18:02:44+00:00"
},
{
"name": "masterminds/html5",
@@ -11971,11 +11907,11 @@
},
{
"name": "phpstan/phpstan",
- "version": "2.2.1",
+ "version": "2.2.2",
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dea9c8f2d25cc849391042b71e429c1a4bf82660",
- "reference": "dea9c8f2d25cc849391042b71e429c1a4bf82660",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6",
+ "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6",
"shasum": ""
},
"require": {
@@ -12031,7 +11967,7 @@
"type": "github"
}
],
- "time": "2026-05-28T14:44:12+00:00"
+ "time": "2026-06-05T09:00:01+00:00"
},
{
"name": "phpstan/phpstan-deprecation-rules",
@@ -12085,16 +12021,16 @@
},
{
"name": "phpstan/phpstan-doctrine",
- "version": "2.0.23",
+ "version": "2.0.25",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-doctrine.git",
- "reference": "4821d678585be36f585273de38c06b7e4a98bb91"
+ "reference": "e20e8bf3223ae6eba9c4b5987c391d922e094b3c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/4821d678585be36f585273de38c06b7e4a98bb91",
- "reference": "4821d678585be36f585273de38c06b7e4a98bb91",
+ "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/e20e8bf3223ae6eba9c4b5987c391d922e094b3c",
+ "reference": "e20e8bf3223ae6eba9c4b5987c391d922e094b3c",
"shasum": ""
},
"require": {
@@ -12156,9 +12092,9 @@
],
"support": {
"issues": "https://github.com/phpstan/phpstan-doctrine/issues",
- "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.23"
+ "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.25"
},
- "time": "2026-05-25T15:58:25+00:00"
+ "time": "2026-06-02T20:27:36+00:00"
},
{
"name": "phpstan/phpstan-phpunit",
diff --git a/kimai.sh b/kimai.sh
index 64a8d236..b496ea6b 100755
--- a/kimai.sh
+++ b/kimai.sh
@@ -145,10 +145,10 @@ function install_plugins() {
if [[ ${#packages[@]} -gt 0 ]]; then
verbose "Installing Composer plugins: ${packages[*]}"
run_composer require "${packages[@]} --update-no-dev --optimize-autoloader" || exit 1
- $KIMAI_PHP bin/console kimai:plugins --install || exit 1
- else
- verbose "No Composer plugins detected."
fi
+
+ verbose "Installing plugins"
+ $KIMAI_PHP bin/console kimai:plugins --install || exit 1
}
function set_permission() {
@@ -205,9 +205,9 @@ fi
cd "$(dirname "$0")" || { echo "Cannot change working directory."; exit 1; }
# we need a few commands installed in order for this script to complete
-composer_exists || { echo >&2 "Update requires 'composer' but it's not installed or not executable."; exit 1; }
-command -v git >/dev/null 2>&1 || { echo >&2 "Update requires 'git' but it's not installed."; exit 1; }
-command -v "$KIMAI_PHP" >/dev/null 2>&1 || { echo >&2 "Update requires 'php' but it's not installed."; exit 1; }
+composer_exists || { echo >&2 "Kimai requires 'composer' but it's not installed or not executable."; exit 1; }
+command -v git >/dev/null 2>&1 || { echo >&2 "Kimai requires 'git' but it's not installed."; exit 1; }
+command -v "$KIMAI_PHP" >/dev/null 2>&1 || { echo >&2 "Kimai requires 'php' but it's not installed."; exit 1; }
verbose "Using PHP: $KIMAI_PHP"
verbose "Using Composer: $KIMAI_COMPOSER"
diff --git a/migrations/Version20260530080724.php b/migrations/Version20260530080724.php
index a795ef70..02949703 100644
--- a/migrations/Version20260530080724.php
+++ b/migrations/Version20260530080724.php
@@ -24,16 +24,30 @@ final class Version20260530080724 extends AbstractMigration
public function up(Schema $schema): void
{
- $this->addSql('ALTER TABLE kimai2_users ADD signature_date DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
+ // a security related column
+ if (!$schema->getTable('kimai2_users')->hasColumn('signature_date')) {
+ $this->addSql('ALTER TABLE kimai2_users ADD signature_date DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
+ }
+
// improve session garbage collection
- $this->addSql('CREATE INDEX lifetime_idx ON kimai2_sessions (lifetime)');
+ if (!$schema->getTable('kimai2_sessions')->hasIndex('lifetime_idx')) {
+ $this->addSql('CREATE INDEX lifetime_idx ON kimai2_sessions (lifetime)');
+ }
+
+ $this->preventEmptyMigrationWarning(false);
}
public function down(Schema $schema): void
{
- $this->addSql('ALTER TABLE kimai2_users DROP signature_date');
- // improve session garbage collection
- $this->addSql('DROP INDEX lifetime_idx ON kimai2_sessions');
+ if ($schema->getTable('kimai2_users')->hasColumn('signature_date')) {
+ $this->addSql('ALTER TABLE kimai2_users DROP signature_date');
+ }
+
+ if ($schema->getTable('kimai2_sessions')->hasIndex('lifetime_idx')) {
+ $this->addSql('DROP INDEX lifetime_idx ON kimai2_sessions');
+ }
+
+ $this->preventEmptyMigrationWarning(false);
}
public function isTransactional(): bool
diff --git a/src/Command/InvoiceCreateCommand.php b/src/Command/InvoiceCreateCommand.php
index 0631a9b7..d77cdf9d 100644
--- a/src/Command/InvoiceCreateCommand.php
+++ b/src/Command/InvoiceCreateCommand.php
@@ -238,10 +238,10 @@ final class InvoiceCreateCommand extends Command
$projects[] = $tmp;
}
$invoices = $this->createInvoicesForProjects($projects, $defaultQuery, $input, $output);
- } elseif ($byActiveCustomer) {
+ } elseif ($byActiveCustomer) { // @phpstan-ignore elseif.alwaysFalse
$customers = $this->getActiveCustomers($defaultQuery);
$invoices = $this->createInvoicesForCustomer($customers, $defaultQuery, $input, $output);
- } elseif ($byActiveProject) {
+ } elseif ($byActiveProject) { // @phpstan-ignore elseif.alwaysTrue
$projects = $this->getActiveProjects($defaultQuery);
$invoices = $this->createInvoicesForProjects($projects, $defaultQuery, $input, $output);
} else {
diff --git a/src/Constants.php b/src/Constants.php
index 0eb673f9..ebf68e34 100644
--- a/src/Constants.php
+++ b/src/Constants.php
@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
- public const VERSION = '2.59.0';
+ public const VERSION = '2.60.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
- public const VERSION_ID = 25900;
+ public const VERSION_ID = 26000;
/**
* The software name
*/
diff --git a/src/Doctrine/AbstractMigration.php b/src/Doctrine/AbstractMigration.php
index 07b331ab..a306a0ba 100644
--- a/src/Doctrine/AbstractMigration.php
+++ b/src/Doctrine/AbstractMigration.php
@@ -58,9 +58,11 @@ abstract class AbstractMigration extends BaseAbstractMigration
}
}
- protected function preventEmptyMigrationWarning(): void
+ protected function preventEmptyMigrationWarning(bool $always = true): void
{
- $this->addSql('#prevent empty warning - no SQL to execute');
+ if ($always || \count($this->getSql()) === 0) {
+ $this->addSql('#prevent empty warning - no SQL to execute');
+ }
}
/**
diff --git a/src/Invoice/Hydrator/InvoiceModelActivityHydrator.php b/src/Invoice/Hydrator/InvoiceModelActivityHydrator.php
index 2a2b4a30..32cca63a 100644
--- a/src/Invoice/Hydrator/InvoiceModelActivityHydrator.php
+++ b/src/Invoice/Hydrator/InvoiceModelActivityHydrator.php
@@ -37,13 +37,18 @@ final class InvoiceModelActivityHydrator implements InvoiceModelHydrator
}
}
- if (\count($activities) === 0) {
- return [];
+ $counter = \count($activities);
+
+ $values = [
+ 'activity.counter' => $counter,
+ ];
+
+ if ($counter === 0) {
+ return $values;
}
$activities = array_values($activities);
- $values = [];
$i = 0;
foreach ($activities as $activity) {
diff --git a/src/Invoice/Hydrator/InvoiceModelProjectHydrator.php b/src/Invoice/Hydrator/InvoiceModelProjectHydrator.php
index 41080a0c..53b764ca 100644
--- a/src/Invoice/Hydrator/InvoiceModelProjectHydrator.php
+++ b/src/Invoice/Hydrator/InvoiceModelProjectHydrator.php
@@ -40,7 +40,7 @@ final class InvoiceModelProjectHydrator implements InvoiceModelHydrator
$counter = \count($projects);
$values = [
- 'project._counter' => $counter,
+ 'project.counter' => $counter,
];
if ($counter === 0) {
diff --git a/src/Pdf/PdfContext.php b/src/Pdf/PdfContext.php
index 347dd704..1065d014 100644
--- a/src/Pdf/PdfContext.php
+++ b/src/Pdf/PdfContext.php
@@ -26,6 +26,9 @@ final class PdfContext
'filename', 'mode', 'format', 'orientation', 'default_font', 'default_font_size', 'fonts',
'margin_left', 'margin_right', 'margin_top', 'margin_bottom', 'margin_header', 'margin_footer',
'setAutoTopMargin', 'setAutoBottomMargin', 'PDFA', 'PDFAauto', 'useActiveForms',
+ // Adding watermarks to PDFs
+ 'watermarkImgBehind', 'showWatermarkText', 'showWatermarkImage', 'watermarkText', 'watermarkAngle',
+ 'watermarkImage', 'watermark_font', 'watermarkTextAlpha', 'watermarkImageAlpha',
];
private array $options = [];
diff --git a/src/Repository/Query/InvoiceArchiveQuery.php b/src/Repository/Query/InvoiceArchiveQuery.php
index 71d09488..6154de89 100644
--- a/src/Repository/Query/InvoiceArchiveQuery.php
+++ b/src/Repository/Query/InvoiceArchiveQuery.php
@@ -45,6 +45,7 @@ class InvoiceArchiveQuery extends BaseQuery implements DateRangeInterface
'dateRange' => new DateRange(),
'customers' => [],
'status' => [],
+ 'users' => [],
]);
}
diff --git a/src/Repository/Search/SearchHelper.php b/src/Repository/Search/SearchHelper.php
index 132a7a18..a8ec2350 100644
--- a/src/Repository/Search/SearchHelper.php
+++ b/src/Repository/Search/SearchHelper.php
@@ -51,6 +51,7 @@ final class SearchHelper
$i = 0;
$c = 0;
$j = 0;
+ $k = 0;
foreach ($searchTerm->getParts() as $part) {
// we do NOT search for unspecific/global terms as of now, because it is not clear if the user wants that
if (($metaName = $part->getField()) === null) {
@@ -61,7 +62,7 @@ final class SearchHelper
$metaValue = $part->getTerm();
$paramName = 'metaName' . $i++;
$paramValue = 'metaValue' . $c++;
- $subqueryName = 'metaNotExists' . $metaName;
+ $subqueryName = 'metaNotExists' . $k++;
$field = $alias . '.value';
$and = $qb->expr()->andX();
diff --git a/src/Utils/SearchTerm.php b/src/Utils/SearchTerm.php
index 87fafb23..cedbe244 100644
--- a/src/Utils/SearchTerm.php
+++ b/src/Utils/SearchTerm.php
@@ -21,7 +21,10 @@ final class SearchTerm
public function __construct(string $searchTerm)
{
$this->originalTerm = $searchTerm;
- $terms = explode(' ', $searchTerm);
+ $terms = preg_split('/\s+/', trim($searchTerm), -1, PREG_SPLIT_NO_EMPTY);
+ if (!\is_array($terms)) {
+ $terms = [];
+ }
$finalTerm = [];
foreach ($terms as $term) {
diff --git a/templates/invoice/renderer/default.pdf.twig b/templates/invoice/renderer/default.pdf.twig
index e2fc4cc9..d131cbf9 100644
--- a/templates/invoice/renderer/default.pdf.twig
+++ b/templates/invoice/renderer/default.pdf.twig
@@ -92,15 +92,19 @@ mpdf-->
{{ invoice['customer.number'] }}
{% endif %}
- {% if invoice['query.project.order_number'] is defined and invoice['query.project.order_number'] is not empty %}
+ {% if invoice['project.counter'] == 1 and invoice['project.order_number'] is not empty %}
{{ 'orderNumber'|trans }}:
- {{ invoice['query.project.order_number'] }}
+ {{ invoice['project.order_number'] }}
{% endif %}
{% if invoice['customer.invoice_text'] is not empty%}
- {{ invoice['customer.invoice_text']|md2html }}
+ {{ invoice['customer.invoice_text']|md2html }}
+ {% endif %}
+
+ {% if invoice['project.counter'] == 1 and invoice['project.invoice_text'] is not empty %}
+ {{ invoice['project.invoice_text']|md2html }}
{% endif %}
diff --git a/templates/invoice/renderer/service-date.pdf.twig b/templates/invoice/renderer/service-date.pdf.twig
index 361e49d2..792b430a 100644
--- a/templates/invoice/renderer/service-date.pdf.twig
+++ b/templates/invoice/renderer/service-date.pdf.twig
@@ -70,6 +70,9 @@ mpdf-->
{% if country not in invoice['customer.address'] %}
{{ country }}
{% endif %}
+ {% if invoice['customer.vat_id'] is not empty %}
+
{{ 'vat_id'|trans }}: {{ invoice['customer.vat_id'] }}
+ {% endif %}
{% set classLeft = 'text-left' %}
@@ -103,16 +106,10 @@ mpdf-->
| {{ invoice['customer.number'] }} |
{% endif %}
- {% if invoice['query.project.order_number'] is defined and invoice['query.project.order_number'] is not empty %}
+ {% if invoice['project.counter'] == 1 and invoice['project.order_number'] is not empty %}
| {{ 'orderNumber'|trans }} |
- {{ invoice['query.project.order_number'] }} |
-
- {% endif %}
- {% if invoice['customer.vat_id'] is not empty %}
-
- | {{ 'vat_id'|trans }} |
- {{ invoice['customer.vat_id'] }} |
+ {{ invoice['project.order_number'] }} |
{% endif %}
@@ -120,10 +117,14 @@ mpdf-->
- {{ invoice['template.title'] }}
+ {{ invoice['template.title'] }}
{% if invoice['customer.invoice_text'] is not empty%}
- {{ invoice['customer.invoice_text']|md2html }}
+ {{ invoice['customer.invoice_text']|md2html }}
+ {% endif %}
+
+ {% if invoice['project.counter'] == 1 and invoice['project.invoice_text'] is not empty %}
+ {{ invoice['project.invoice_text']|md2html }}
{% endif %}
diff --git a/tests/Invoice/Hydrator/InvoiceModelActivityHydratorTest.php b/tests/Invoice/Hydrator/InvoiceModelActivityHydratorTest.php
index 227a87e3..820b9788 100644
--- a/tests/Invoice/Hydrator/InvoiceModelActivityHydratorTest.php
+++ b/tests/Invoice/Hydrator/InvoiceModelActivityHydratorTest.php
@@ -35,6 +35,7 @@ class InvoiceModelActivityHydratorTest extends TestCase
$keys = [
'activity.id',
'activity.name',
+ 'activity.counter',
'activity.comment',
'activity.number',
'activity.invoice_text',
diff --git a/tests/Invoice/Hydrator/InvoiceModelProjectHydratorTest.php b/tests/Invoice/Hydrator/InvoiceModelProjectHydratorTest.php
index 8db0e1d1..73f621e7 100644
--- a/tests/Invoice/Hydrator/InvoiceModelProjectHydratorTest.php
+++ b/tests/Invoice/Hydrator/InvoiceModelProjectHydratorTest.php
@@ -33,10 +33,10 @@ class InvoiceModelProjectHydratorTest extends TestCase
public function assertModelStructure(array $model): void
{
$keys = [
- 'project._counter',
'project.id',
'project.name',
'project.comment',
+ 'project.counter',
'project.number',
'project.invoice_text',
'project.order_date',
diff --git a/tests/Invoice/Renderer/DebugRendererTest.php b/tests/Invoice/Renderer/DebugRendererTest.php
index 45822302..111f71e3 100644
--- a/tests/Invoice/Renderer/DebugRendererTest.php
+++ b/tests/Invoice/Renderer/DebugRendererTest.php
@@ -215,7 +215,8 @@ class DebugRendererTest extends TestCase
'user.meta.hello',
'user.meta.kitty',
'testFromModelHydrator',
- 'project._counter',
+ 'project.counter',
+ 'activity.counter',
];
if ($activityCounter === 1) {
diff --git a/tests/Repository/Search/SearchHelperTest.php b/tests/Repository/Search/SearchHelperTest.php
index 33f974c9..ec818a17 100644
--- a/tests/Repository/Search/SearchHelperTest.php
+++ b/tests/Repository/Search/SearchHelperTest.php
@@ -23,6 +23,7 @@ use Doctrine\ORM\Query\Expr\Orx;
use Doctrine\ORM\Query\Parameter;
use Doctrine\ORM\QueryBuilder;
use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
#[CoversClass(SearchHelper::class)]
@@ -198,4 +199,57 @@ class SearchHelperTest extends TestCase
$sut->addSearchTerm($qb, $query);
}
+
+ public static function provideMaliciousMetaFieldPayloads(): array
+ {
+ $exploitPayload = "metaNotExists1\tFROM\tApp\\Entity\\User\tmetaNotExists1\tWHERE\tmetaNotExists1.id=1)--";
+
+ return [
+ 'dot and parenthesis characters' => ['x.y:~ x):""', ['x.y', 'x)']],
+ 'tab and comment injection payloads' => [$exploitPayload . ':~ ' . $exploitPayload . ':""', ['metaNotExists1.id=1)--', 'metaNotExists1.id=1)--']],
+ ];
+ }
+
+ /**
+ * Regression test for GHSA-9cxw-hp3c-637x
+ */
+ #[DataProvider('provideMaliciousMetaFieldPayloads')]
+ public function testMaliciousMetaFieldNamesCannotInfluenceSubqueryDql(string $term, array $expectedMetaNames): void
+ {
+ $em = $this->createMock(EntityManagerInterface::class);
+ $em->method('getExpressionBuilder')->willReturn(new Expr());
+ $qb = new QueryBuilder($em);
+ $qb->from(Timesheet::class, 'testFoo');
+
+ $query = new BaseQuery();
+ $query->setSearchTerm(new SearchTerm($term));
+ $configuration = new SearchConfiguration(['bar'], 'MetaFieldClass', 'metaFieldName');
+ $configuration->setEntityFieldName('entityFieldName');
+
+ $sut = new SearchHelper($configuration);
+
+ $sut->addSearchTerm($qb, $query);
+
+ $wherePart = $qb->getDQLPart('where');
+ self::assertInstanceOf(Andx::class, $wherePart);
+ $where = (string) $wherePart;
+
+ self::assertStringContainsString('metaNotExists0', $where);
+ self::assertStringContainsString('metaNotExists1', $where);
+ self::assertStringNotContainsString('App\\Entity\\User', $where);
+ self::assertStringNotContainsString('--', $where);
+ self::assertStringNotContainsString("\tFROM\t", $where);
+
+ $metaNames = [];
+ /** @var Parameter $parameter */
+ foreach ($qb->getParameters() as $parameter) {
+ if (str_starts_with($parameter->getName(), 'metaName')) {
+ $metaNames[] = $parameter->getValue();
+ }
+ }
+
+ self::assertCount(2, $metaNames);
+ self::assertEquals($expectedMetaNames[0], $metaNames[0]);
+ self::assertEquals($expectedMetaNames[1], $metaNames[1]);
+ }
}
diff --git a/tests/Utils/SearchTermTest.php b/tests/Utils/SearchTermTest.php
index a6a167b3..b0db6a05 100644
--- a/tests/Utils/SearchTermTest.php
+++ b/tests/Utils/SearchTermTest.php
@@ -170,4 +170,39 @@ class SearchTermTest extends TestCase
self::assertEquals($expected[2], $part->isExcluded());
}
}
+
+ public function testWhitespaceIsNormalizedWhenTokenizing(): void
+ {
+ $sut = new SearchTerm(" \tfoo\tbar:baz\nhello world\t ");
+
+ self::assertTrue($sut->hasSearchTerm());
+ self::assertEquals('foo hello world', $sut->getSearchTerm());
+ self::assertEquals(['bar' => 'baz'], $sut->getSearchFields());
+ self::assertEquals(" \tfoo\tbar:baz\nhello world\t ", $sut->getOriginalSearch());
+ self::assertCount(4, $sut->getParts());
+
+ $expectedParts = [
+ ['foo', null, false],
+ ['baz', 'bar', false],
+ ['hello', null, false],
+ ['world', null, false],
+ ];
+ $i = 0;
+ foreach ($sut->getParts() as $part) {
+ $expected = $expectedParts[$i++];
+ self::assertEquals($expected[0], $part->getTerm());
+ self::assertEquals($expected[1], $part->getField());
+ self::assertEquals($expected[2], $part->isExcluded());
+ }
+ }
+
+ public function testWhitespaceOnlySearchTermCreatesNoParts(): void
+ {
+ $sut = new SearchTerm(" \t \n ");
+
+ self::assertFalse($sut->hasSearchTerm());
+ self::assertEquals('', $sut->getSearchTerm());
+ self::assertEquals([], $sut->getSearchFields());
+ self::assertCount(0, $sut->getParts());
+ }
}