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 %} {% 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 %} - - - {% endif %} - {% if invoice['customer.vat_id'] is not empty %} - - - + {% endif %}
{% set classLeft = 'text-left' %} @@ -103,16 +106,10 @@ mpdf--> {{ invoice['customer.number'] }}
{{ 'orderNumber'|trans }}{{ invoice['query.project.order_number'] }}
{{ 'vat_id'|trans }}{{ invoice['customer.vat_id'] }}{{ invoice['project.order_number'] }}
@@ -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()); + } }