Compare commits

...

22 Commits

Author SHA1 Message Date
jekkos
629de65e73 Merge branch 'master' into fix/4554-encryption-docker-issue 2026-05-22 16:08:37 +02:00
jekkos
5450404cb2 fix: cast string returns to int in MY_Migration (#4560)
basename() returns string and database column values are strings,
but get_latest_migration() and get_current_version() declare int
return types. PHP 8.0+ enforces strict return types and no longer
silently coerces strings to int, causing a TypeError on fresh
installs.

Fixes #4559

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-22 16:07:21 +02:00
Ollama
bf5af2f2dc fix: address CodeRabbit review comments for encryption key persistence
- Always mirror encryption key to both .env and WRITEPATH (Docker safety)
- Guard array key access with isset() before reading in Encryption.php
- Fix encrypt_value() to not treat string '0' as empty
- Improve error logging for failed encryption attempts
2026-05-22 14:57:37 +02:00
Ollama
71c164ad18 Address CodeRabbit feedback: validate key length, clarify encryption failure handling
- loadKeyFromWritable() now validates key length >= 64 before accepting
- encrypt_value() renamed  param, defaults to failing encryption required
- Clearer error message when credentials not saved

GitHub-Issue: #4554
2026-05-22 01:38:30 +02:00
Ollama
f7280ea83e Refactor all encryption/decryption to use helper functions
Replaces direct encrypter calls with decrypt_value() and encrypt_value()
helpers throughout the codebase for consistent error handling:

- Config controller: SMTP, SMS, Mailchimp credential encryption
- Email_lib: SMTP password decryption
- Sms_lib: SMS password decryption
- Mailchimp_lib: API key decryption
- Customers controller: Mailchimp list ID decryption

Removes nullable EncrypterInterface property from Config controller as
encryption is now handled via helper functions.

GitHub-Issue: #4554
2026-05-22 01:28:35 +02:00
Ollama
26e8d9d80c Add decrypt_value() and encrypt_value() helper functions
Extracts the recurring decryption/encryption pattern into reusable helper
functions with consistent error handling:

- decrypt_value(): Safely decrypts encrypted values with try/catch
- encrypt_value(): Safely encrypts values with error handling

Both functions handle:
- Empty/null values gracefully
- Missing encryption key (logs warning)
- Encryption/decryption failures (logs error, returns default)

This pattern appears in 8+ locations across the codebase.

GitHub-Issue: #4554
2026-05-22 01:28:30 +02:00
objecttothis
b7384296c1 Bugfix: Sale search in register not handling trailing space properly (#4557)
* Fix is_valid_receipt method bug

Strings submitted with a trailing space and no number caused an unhandled exception because Sale::exists() expects an int but a string was passed to it.

- Add guards
- Minor PSR refactor

Signed-off-by: objec <objecttothis@gmail.com>

* Address review comments

Signed-off-by: objec <objecttothis@gmail.com>

---------

Signed-off-by: objec <objecttothis@gmail.com>
2026-05-22 01:43:24 +04:00
Ollama
403180db7b Add encryption_failed error message to language file
Added localization string for encryption failure error messages.

GitHub-Issue: #4554
2026-05-21 21:43:28 +02:00
Ollama
6d970953b6 Handle encryption unavailability gracefully in controllers
Changed EncrypterInterface property to nullable and added proper error
handling for cases where encryption key is not available.

Changes:
- Config controller: nullable encrypter property, try/catch around encryption
- Email_lib: check encryption before using encrypter
- Return meaningful error messages when encryption fails
- Log warnings when passwords saved without encryption

Users will now see clear error messages instead of unhandled exceptions
when encryption key cannot be initialized.

GitHub-Issue: #4554
2026-05-21 21:43:24 +02:00
Ollama
b2c2d350a7 Add fallback key loading from WRITEPATH in Encryption config
When encryption key is not available from .env or environment variables,
the config now attempts to load from WRITEPATH/config/encryption.key.

This supports Docker environments where:
- .env file is read-only or ephemeral
- Key was persisted to the writable volume via check_encryption()

GitHub-Issue: #4554
2026-05-21 21:43:18 +02:00
Ollama
9dc58a2c1f Fix encryption key persistence for Docker environments
The check_encryption() function now properly handles Docker/container
environments where ROOTPATH/.env may be read-only or ephemeral.

Changes:
- Returns false when key persistence fails instead of always returning true
- Removes error suppression (@) to properly detect write failures
- Adds fallback to WRITEPATH/config/encryption.key for container volumes
- Splits logic into separate functions for clarity and testability

Fixes encryption key being lost on container restarts, which caused
stored passwords to become undecryptable.

GitHub-Issue: #4554
2026-05-21 21:43:14 +02:00
objecttothis
b0dddc22a3 Bugfixes to get Migration working on MySQL and MariaDB (#4551)
* Bugfixes to get Migration working on MySQL

Signed-off-by: objec <objecttothis@gmail.com>

* MariaDB compatibility fixes

- Drop foreign key constraints before making charset changes
- Fix dropAllForeignKeyConstraints helper function.
- Added `IF EXISTS` to DROP statements
- Do not try to readd FK constraints for tables which were dropped.
- MariaDB 11.8.x changes the default implicit collation to uca1400 which breaks the IndiaGST migration, et. al. Explicitly declare utf8_general_ci in affected migrations.

Signed-off-by: objec <objecttothis@gmail.com>

* Fix changes which break MySQL migrations

- MySQL does not support IF EXISTS in foreign key constraints. Since the PHP is now handling dropping those constraints, these lines are redundant. Remove them.

Signed-off-by: objec <objecttothis@gmail.com>

* Resolve code review recommendations

- Add try/catch around DB connect statement
- Heed result of execute_script function and throw an exception on failure.

Signed-off-by: objec <objecttothis@gmail.com>

* Refactor out duplicate code

Signed-off-by: objec <objecttothis@gmail.com>

* Initialize array variable causing potential issues

Signed-off-by: objec <objecttothis@gmail.com>

---------

Signed-off-by: objec <objecttothis@gmail.com>
2026-05-19 16:02:05 +04:00
jekkos
8d6b166673 feat: Add deployment workflow with approval gates (#4522)
* feat: Add deployment workflows with approval gates

Add GitHub Actions workflows for controlled deployments:

deploy.yml - Manual Deploy:
- Triggered via Actions UI (workflow_dispatch)
- Select environment (production/staging)
- Select Docker image tag
- Reusable via workflow_call for other workflows
- Creates GitHub deployment records with status tracking
- Sends Docker Hub compatible webhook payload
- Environment input validation for workflow_call

deploy-pr.yml - PR Deploy:
- Auto-triggers when PR is approved (same-repo only)
- Deploys to staging environment
- Image tag format: pr-{number}-{short-sha}
- Posts deployment status as PR comment
- Fork PR protection: only runs for same-repo PRs

Security:
- jq-based JSON payload construction (prevents script injection)
- HMAC-SHA256 signature verification for webhook
- Untrusted inputs via env: blocks (not inline interpolation)
- Environment validation before deployment
- Fork detection guard for PR deployments

Fixes CodeRabbit review comments:
- Invalid jq string filter syntax (missing quotes)
- Unvalidated environment input in workflow_call
- Fork PR deployments blocked by pull_request_review restrictions

* refactor: Limit deployment to staging only

- Remove environment input choice (was production/staging)
- Hardcode environment to 'staging' throughout
- Simplify workflow - no environment validation needed
- Update concurrency group to deploy-staging

* refactor: Extract deployment logic to reusable deploy-core.yml

Restructure workflows to eliminate code duplication:

deploy-core.yml (new):
- Reusable workflow with all deployment logic
- Creates GitHub deployment record
- Sends webhook payload to external service
- Handles status updates
- Accepts image_tag, sha, description, pr_number inputs
- Outputs deployment_id and status

deploy.yml (simplified):
- Manual trigger only
- Calls deploy-core with user-provided image_tag
- 18 lines (was 175)

deploy-pr.yml (simplified):
- PR approval trigger with fork guard
- Prepare job: checkout, generate PR image tag
- Deploy job: calls deploy-core
- Comment job: post status to PR
- 70 lines (was 204)

---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-18 21:48:02 +02:00
jekkos
093ec7fb13 fix: validate attributeId > 0 in saveAttributeLink() (#4508)
- Add early validation to reject attributeId <= 0
- Ensure consistent handling of invalid attribute_id in INSERT/UPDATE paths
- Prevent foreign key constraint violations from invalid attribute references

Fixes #4460

Co-authored-by: Ollama <ollama@steganos.dev>
Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-05-18 14:13:20 +02:00
jekkos
9c89a2e2cb fix: Capture CSV import failures in save_tax_data and save_inventory_quantities (#4507)
* fix: capture CSV import failures in save_tax_data and save_inventory_quantities

- Change save_tax_data() return type from void to bool
- Change save_inventory_quantities() return type from void to bool
- Accumulate failure status with &= operator in save_inventory_quantities
- Update postImportCsvFile() to capture return values and set isFailedRow
- Properly propagate failures to failCodes array

Fixes #4475

* fix: Change isset to !empty for items_taxes_data check

- isset was always true since array was initialized
- Use !empty to properly check if there are tax items to save

Address CodeRabbit review feedback

* fix: Capture inventory insert result in save_inventory_quantities

- Combine inventory insert result with success tracking
- Use &= operator to accumulate failures from both operations
- Ensure failures from inventory inserts are propagated

Address CodeRabbit review feedback

---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-17 22:23:43 +02:00
jekkos
2f51c4ef52 fix(security): SQL injection and path traversal vulnerabilities (#4539)
Security fixes for two vulnerabilities:

1. SQL Injection in Summary Sales Taxes Report (GHSA-5j9m-2f98-cjqw)
   - Fixed unsanitized user input concatenation in getData() method
   - Applied proper escaping using $this->db->escape() for start_date/end_date
   - Consistent with existing _where() method implementation

2. Path Traversal in Receipt Template (GHSA-h6wm-fhw2-m3q3)
   - Added ALLOWED_RECEIPT_TEMPLATES whitelist constant
   - Added isValidReceiptTemplate() validation method
   - Validate receipt_template before saving in Config controller
   - Validate receipt_template before rendering in receipt view
   - Default to 'receipt_default' for invalid values
   - Consistent with invoice_type fix pattern (commit 31d25e06d)

Affected files:
- app/Models/Reports/Summary_sales_taxes.php
- app/Libraries/Sale_lib.php
- app/Controllers/Config.php
- app/Views/sales/receipt.php

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-15 23:10:04 +02:00
jekkos
def0c27a0e fix(security): Path traversal vulnerability in getPicThumb (#4545)
Security impact:
- Authenticated attackers could read arbitrary files on the server
- Path traversal via unsanitized pic_filename parameter
- Could read .env, config files, encryption keys

Fix:
- Apply basename() to strip directory components
- Validate file extension to allowlist image types only
- Add explicit error response for invalid file types

CVE: Pending
Affected: <= 3.4.2
Reported by: Kamran Saifullah (VulDB)

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-15 22:04:29 +02:00
BhojKamal
90c981b6b7 feat: Bank transfer and wallet payment option added #4540 (#4547)
---------

Co-authored-by: Lotussoft Youngtech <lotussoftyoungtech@gmail.com>
Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-05-15 20:50:34 +02:00
jekkos
6ff28d8a4d docs: Update SECURITY.md with disclosure process (#4549)
* docs: Update SECURITY.md with disclosure process and advisory template

- Update published advisories table with CVE-2026-41306 and CVE-2026-41307
- Add disclosure process timeline
- Add vulnerability template for researchers
- Explain GitHub advisory creation workflow
- Document security best practices for researchers

This streamlines the vulnerability reporting process by allowing
researchers to create draft advisories directly on GitHub, reducing
triage overhead.

* docs: Update SECURITY.md with CVE process and reporter acknowledgments

- Add CVE request procedure through GitHub
- Document that existing CVEs should be shared in reports
- Clarify no bug bounty program (voluntary triage)
- Add security best practices for researchers
- Thank security researchers for contributions
- Explain vulnerability template format

* docs: Simplify SECURITY.md - remove CVE table, link to GitHub advisories

---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-15 12:24:39 +02:00
jekkos
70fb347fc4 fix(docker): correct permissions and fix migration barcode_type error (#4546)
* fix(ci): include hidden files in Docker build context

actions/upload-artifact@v4 excludes hidden files (dotfiles) by default,
causing .htaccess files to be missing from the Docker image. Add
include-hidden-files: true to preserve .htaccess in the build artifact.

* fix(docker): correct permissions and add barcode_type default

- Set proper permissions (750) for writable/logs, writable/uploads,
  writable/cache, public/uploads, and public/uploads/item_pics
- Set permissions (640) for writable/uploads/importCustomers.csv
- Add barcode_type default value to prevent 'unknown key' error
  during initial migration when database is not yet initialized

---------

Co-authored-by: Ollama <ollama@steganos.dev>
Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-05-13 20:55:59 +02:00
jekkos
2f5c0130f4 feat: add ALLOWED_HOSTNAMES environment variable support for Docker/Compose (#4544)
Allow configuring allowed hostnames via ALLOWED_HOSTNAMES environment
variable as an alternative to app.allowedHostnames in .env file. This
is more convenient for Docker/Compose deployments where environment
variables are set directly in compose files.

The ALLOWED_HOSTNAMES variable takes precedence over app.allowedHostnames
if both are set, allowing deployment-specific overrides.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Ollama <ollama@steganos.dev>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-13 09:03:32 +02:00
jekkos
fdd6a408ec fix(ci): include hidden files in Docker build context (#4543)
actions/upload-artifact@v4 excludes hidden files (dotfiles) by default,
causing .htaccess files to be missing from the Docker image. Add
include-hidden-files: true to preserve .htaccess in the build artifact.

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-13 07:06:23 +02:00
44 changed files with 1164 additions and 262 deletions

View File

@@ -16,6 +16,9 @@ CI_ENVIRONMENT = production
# Configure with comma-separated list of domains/subdomains:
# app.allowedHostnames = 'yourdomain.com,www.yourdomain.com'
#
# Or via environment variable (useful for Docker/Compose):
# ALLOWED_HOSTNAMES=yourdomain.com,www.yourdomain.com
#
# For local development:
# app.allowedHostnames = 'localhost'
#

View File

@@ -123,6 +123,7 @@ jobs:
.
!.git
!node_modules
include-hidden-files: true
retention-days: 1
docker:

219
.github/workflows/deploy-core.yml vendored Normal file
View File

@@ -0,0 +1,219 @@
name: Deploy Core
on:
workflow_call:
inputs:
image_tag:
description: 'Docker image tag to deploy'
type: string
required: true
sha:
description: 'Git commit SHA to deploy'
type: string
required: true
description:
description: 'Deployment description'
type: string
required: true
pr_number:
description: 'Pull request number (optional)'
type: string
required: false
outputs:
deployment_id:
description: 'GitHub deployment ID'
value: ${{ jobs.deploy.outputs.deployment_id }}
status:
description: 'Deployment status (success/failure)'
value: ${{ jobs.deploy.outputs.status }}
concurrency:
group: deploy-staging
cancel-in-progress: false
permissions:
contents: read
deployments: write
jobs:
deploy:
name: Deploy to staging
runs-on: ubuntu-latest
environment:
name: staging
url: ${{ vars.DEPLOY_URL || 'https://dev.opensourcepos.org' }}
deployment: false
outputs:
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
status: ${{ steps.webhook.outputs.status }}
steps:
- name: Create GitHub Deployment
id: deployment
env:
GH_TOKEN: ${{ github.token }}
IMAGE_TAG: ${{ inputs.image_tag }}
REF_SHA: ${{ inputs.sha }}
DESCRIPTION: ${{ inputs.description }}
run: |
set -euo pipefail
DEPLOYMENT_ID=$(gh api "repos/${GITHUB_REPOSITORY}/deployments" \
-X POST \
-f ref="${REF_SHA}" \
-f environment="staging" \
-f description="${DESCRIPTION}" \
-F auto_merge=false \
-F required_contexts[] \
--jq '.id')
if [ -z "$DEPLOYMENT_ID" ]; then
echo "::error::Failed to create deployment"
exit 1
fi
echo "deployment_id=$DEPLOYMENT_ID" >> "$GITHUB_OUTPUT"
echo "Created deployment: $DEPLOYMENT_ID"
- name: Set deployment status to in_progress
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh api "repos/${GITHUB_REPOSITORY}/deployments/${{ steps.deployment.outputs.deployment_id }}/statuses" \
-X POST \
-f state="in_progress" \
-f description="Deployment in progress..." \
-f log_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
- name: Trigger deployment webhook
id: webhook
env:
DEPLOY_WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
DEPLOY_WEBHOOK_SECRET: ${{ secrets.DEPLOY_WEBHOOK_SECRET }}
DOCKER_REPO_NAME: ${{ secrets.DOCKER_REPO_NAME }}
IMAGE_TAG: ${{ inputs.image_tag }}
REF_SHA: ${{ inputs.sha }}
DEPLOYMENT_ID: ${{ steps.deployment.outputs.deployment_id }}
PR_NUMBER: ${{ inputs.pr_number }}
run: |
set -euo pipefail
if [ -z "$DEPLOY_WEBHOOK_URL" ]; then
echo "::error::DEPLOY_WEBHOOK_URL secret is not configured"
echo "Please add the DEPLOY_WEBHOOK_URL secret in your repository settings"
echo "status=failure" >> "$GITHUB_OUTPUT"
exit 1
fi
REPO_NAME="${DOCKER_REPO_NAME:-opensourcepos/opensourcepos}"
REPO_NAMESPACE="${REPO_NAME%%/*}"
REPO_SHORT_NAME="${REPO_NAME#*/}"
PUSHED_AT=$(date +%s)
if [ -n "$PR_NUMBER" ]; then
PAYLOAD=$(jq -n \
--arg callback_url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--argjson pushed_at "$PUSHED_AT" \
--arg pusher "$GITHUB_ACTOR" \
--arg tag "$IMAGE_TAG" \
--arg repo_name "$REPO_NAME" \
--arg name "$REPO_SHORT_NAME" \
--arg namespace "$REPO_NAMESPACE" \
--arg repo_url "https://hub.docker.com/r/${REPO_NAME}/" \
--arg deployment_id "$DEPLOYMENT_ID" \
--arg repository "$GITHUB_REPOSITORY" \
--arg sha "$REF_SHA" \
--arg run_id "$GITHUB_RUN_ID" \
--arg actor "$GITHUB_ACTOR" \
--argjson pr_number "$PR_NUMBER" \
'{
callback_url: $callback_url,
push_data: {pushed_at: $pushed_at, pusher: $pusher, tag: $tag},
repository: {repo_name: $repo_name, name: $name, namespace: $namespace, repo_url: $repo_url, status: "Active"},
github_deployment: {id: $deployment_id, environment: "staging", repository: $repository, sha: $sha, run_id: $run_id, actor: $actor, pull_request: $pr_number}
}')
else
PAYLOAD=$(jq -n \
--arg callback_url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--argjson pushed_at "$PUSHED_AT" \
--arg pusher "$GITHUB_ACTOR" \
--arg tag "$IMAGE_TAG" \
--arg repo_name "$REPO_NAME" \
--arg name "$REPO_SHORT_NAME" \
--arg namespace "$REPO_NAMESPACE" \
--arg repo_url "https://hub.docker.com/r/${REPO_NAME}/" \
--arg deployment_id "$DEPLOYMENT_ID" \
--arg repository "$GITHUB_REPOSITORY" \
--arg sha "$REF_SHA" \
--arg run_id "$GITHUB_RUN_ID" \
--arg actor "$GITHUB_ACTOR" \
'{
callback_url: $callback_url,
push_data: {pushed_at: $pushed_at, pusher: $pusher, tag: $tag},
repository: {repo_name: $repo_name, name: $name, namespace: $namespace, repo_url: $repo_url, status: "Active"},
github_deployment: {id: $deployment_id, environment: "staging", repository: $repository, sha: $sha, run_id: $run_id, actor: $actor}
}')
fi
echo "Sending webhook..."
echo "Image: ${IMAGE_TAG}"
echo "Environment: staging"
HEADERS=(-H "Content-Type: application/json")
if [ -n "$DEPLOY_WEBHOOK_SECRET" ]; then
SIGNATURE=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$DEPLOY_WEBHOOK_SECRET" | sed 's/.*= //')
HEADERS+=(-H "X-Hub-Signature-256: sha256=$SIGNATURE")
echo "Using HMAC-SHA256 signature verification"
else
echo "::warning::DEPLOY_WEBHOOK_SECRET not set - webhook calls will not be signed"
echo "For security, configure DEPLOY_WEBHOOK_SECRET in your repository settings"
fi
HTTP_CODE=$(curl -sS --connect-timeout 10 --max-time 120 \
-o response.txt -w "%{http_code}" \
-X POST \
"${HEADERS[@]}" \
-d "$PAYLOAD" \
"$DEPLOY_WEBHOOK_URL") || HTTP_CODE="000"
echo "Response code: $HTTP_CODE"
if [ -s response.txt ]; then
cat response.txt
fi
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
echo "status=success" >> "$GITHUB_OUTPUT"
else
echo "status=failure" >> "$GITHUB_OUTPUT"
fi
- name: Set deployment status
if: always()
env:
GH_TOKEN: ${{ github.token }}
IMAGE_TAG: ${{ inputs.image_tag }}
run: |
set -euo pipefail
STATE="${{ steps.webhook.outputs.status }}"
if [ "$STATE" = "success" ]; then
DESCRIPTION=$(jq -nr --arg tag "$IMAGE_TAG" \
'"Deployed image \($tag) to staging"')
gh api "repos/${GITHUB_REPOSITORY}/deployments/${{ steps.deployment.outputs.deployment_id }}/statuses" \
-X POST \
-f state="success" \
-f description="$DESCRIPTION"
else
gh api "repos/${GITHUB_REPOSITORY}/deployments/${{ steps.deployment.outputs.deployment_id }}/statuses" \
-X POST \
-f state="failure" \
-f description="Deployment failed"
exit 1
fi

79
.github/workflows/deploy-pr.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: PR Deploy
on:
pull_request_review:
types: [submitted]
concurrency:
group: staging-deploy
cancel-in-progress: false
permissions:
contents: read
deployments: write
pull-requests: write
jobs:
prepare:
name: Prepare deployment
runs-on: ubuntu-latest
if: >
github.event.review.state == 'approved' &&
github.event.pull_request.head.repo.full_name == github.repository
outputs:
image_tag: ${{ steps.image.outputs.tag }}
sha: ${{ github.event.pull_request.head.sha }}
pr_number: ${{ github.event.pull_request.number }}
steps:
- name: Checkout PR
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Get image tag
id: image
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
run: |
IMAGE_TAG="pr-${PR_NUMBER}-${PR_SHA:0:7}"
echo "tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
deploy:
name: Deploy to staging
needs: prepare
uses: ./.github/workflows/deploy-core.yml
with:
image_tag: ${{ needs.prepare.outputs.image_tag }}
sha: ${{ needs.prepare.outputs.sha }}
description: Deploy PR #${{ needs.prepare.outputs.pr_number }} to staging
pr_number: ${{ needs.prepare.outputs.pr_number }}
secrets: inherit
comment:
name: Comment deployment status
needs: [prepare, deploy]
if: always()
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PR_NUMBER: ${{ needs.prepare.outputs.pr_number }}
REF_SHA: ${{ needs.prepare.outputs.sha }}
STATUS: ${{ needs.deploy.outputs.status }}
steps:
- name: Comment on PR
run: |
if [ "$STATUS" = "success" ]; then
BODY=$(jq -nr --arg tag "$IMAGE_TAG" --arg sha "$REF_SHA" --arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
'"✅ **Staging deployment completed**\n\n🔗 **URL**: https://dev.opensourcepos.org\n📦 **Image Tag**: `\($tag)`\n🔨 **Commit**: \($sha)\n\nView logs: \($url)"')
else
BODY=$(jq -nr --arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
'"❌ **Staging deployment failed**\n\nCheck the [workflow logs](\($url)) for details."')
fi
gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
-X POST \
-f body="$BODY"

23
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Deploy
on:
workflow_dispatch:
inputs:
image_tag:
description: 'Docker image tag to deploy (e.g., v3.4.0, latest)'
required: true
default: 'latest'
permissions:
contents: read
deployments: write
jobs:
deploy:
name: Deploy to staging
uses: ./.github/workflows/deploy-core.yml
with:
image_tag: ${{ inputs.image_tag }}
sha: ${{ github.sha }}
description: Deploy image ${{ inputs.image_tag }}
secrets: inherit

View File

@@ -13,7 +13,8 @@ RUN echo "date.timezone = \"\${PHP_TIMEZONE}\"" > /usr/local/etc/php/conf.d/time
WORKDIR /app
COPY --chown=www-data:www-data . /app
RUN chmod 770 /app/writable/uploads /app/writable/logs /app/writable/cache \
RUN chmod 750 /app/writable/logs /app/writable/uploads /app/writable/cache /app/public/uploads /app/public/uploads/item_pics \
&& chmod 640 /app/writable/uploads/importCustomers.csv \
&& ln -s /app/*[^public] /var/www \
&& rm -rf /var/www/html \
&& ln -nsf /app/public /var/www/html

View File

@@ -5,8 +5,9 @@
- [Supported Versions](#supported-versions)
- [Security Advisories](#security-advisories)
- [Reporting a Vulnerability](#reporting-a-vulnerability)
- [Disclosure Process](#disclosure-process)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
<!-- END doctoc generated TOC please keep comment here to allow update -->
# Security Policy
@@ -21,26 +22,116 @@ We release patches for security vulnerabilities.
## Security Advisories
The following security vulnerabilities have been published:
### High Severity
| CVE | Vulnerability | CVSS | Published | Fixed In | Credit |
|-----|--------------|------|-----------|----------|--------|
| [CVE-2025-68434](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-wjm4-hfwg-5w5r) | CSRF leading to Admin Creation | 8.8 | 2025-12-17 | 3.4.2 | @Nixon-H, @jekkos |
| [CVE-2025-68147](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-xgr7-7pvw-fpmh) | Stored XSS in Return Policy | 8.1 | 2025-12-17 | 3.4.2 | @Nixon-H, @jekkos |
| [CVE-2025-66924](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-gv8j-f6gq-g59m) | Stored XSS in Item Kits | 7.2 | 2026-03-04 | 3.4.2 | @hungnqdz, @omkaryepre |
### Medium Severity
| CVE | Vulnerability | CVSS | Published | Fixed In | Credit |
|-----|--------------|------|-----------|----------|--------|
| [CVE-2025-68658](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-32r8-8r9r-9chw) | Stored XSS in Company Name | 4.3 | 2026-01-13 | 3.4.2 | @hungnqdz |
For a complete list including draft advisories, see our [GitHub Security Advisories page](https://github.com/opensourcepos/opensourcepos/security/advisories).
For a complete list of published and draft security advisories with CVE details, see our [GitHub Security Advisories page](https://github.com/opensourcepos/opensourcepos/security/advisories).
## Reporting a Vulnerability
Please report (suspected) security vulnerabilities to **[jeroen@steganos.dev](mailto:jeroen@steganos.dev)**.
**Option 1: GitHub Security Advisory (Preferred)**
You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days.
1. Create a draft security advisory directly on GitHub:
- Go to https://github.com/opensourcepos/opensourcepos/security/advisories
- Click "New draft security advisory"
- Fill in the vulnerability details using our [template below](#vulnerability-template)
- Submit as **draft** (not published)
2. Notify us for triage:
- Send an email to **[jeroen@steganos.dev](mailto:jeroen@steganos.dev)** with:
- Subject: `[GHSA] Brief description of vulnerability`
- Link to the draft advisory
- Brief summary
**Option 2: Email Report**
Send vulnerability details to **[jeroen@steganos.dev](mailto:jeroen@steganos.dev)**.
You will receive a response within 48 hours. Confirmed vulnerabilities will be patched within a few days depending on complexity.
## Disclosure Process
### Timeline
| Step | Timeline | Action |
|------|----------|--------|
| 1. Report received | Day 0 | We acknowledge within 48 hours |
| 2. Triage & confirmation | Day 1-3 | We validate the vulnerability |
| 3. Fix development | Day 3-7 | We develop and test the fix |
| 4. Patch release | Day 7-10 | We release a security patch |
| 5. CVE request | Day 7-14 | We request CVE from GitHub (if applicable) |
| 6. Advisory published | Day 14 | We publish the advisory with credit |
| 7. Public disclosure | Day 14+ | Full disclosure after patch release |
### CVE Process
**We request CVE identifiers through GitHub's security advisory system.** This is the preferred and easiest method:
1. After we confirm and fix the vulnerability, we'll request a CVE through GitHub
2. GitHub coordinates with MITRE on our behalf
3. The CVE is automatically linked to the advisory
4. You'll be credited as the reporter in the published advisory
**Already have a CVE?** If you've already obtained a CVE from another source (e.g., VulDB, CVE.MITRE.ORG), please include it in your report or advisory. We'll update our advisory to reference the existing CVE.
### No Bug Bounty Program
**Important:** Open Source Point of Sale does not offer a bug bounty program.
- All security research and vulnerability triage is done on a **voluntary basis** in our free time
- We do not offer monetary rewards for vulnerability reports
- We do credit reporters in published advisories (unless anonymity is requested)
- We greatly appreciate the security research community's efforts to help improve project security
### Security Best Practices for Researchers
- **Do not** access, modify, or delete data that doesn't belong to you
- **Do not** perform denial of service attacks
- **Do not** publicly disclose vulnerabilities before we've had time to fix them
- **Do** provide sufficient information to reproduce the vulnerability
- **Do** allow us reasonable time to fix before public disclosure
- **Do** report through official channels (GitHub advisories or email)
### Vulnerability Template
When creating a draft advisory, please include:
```
## Summary
[Brief description of the vulnerability]
## Impact
- **Confidentiality:** [High/Medium/Low - what data can be exposed]
- **Integrity:** [High/Medium/Low - what can be modified]
- **Availability:** [High/Medium/Low - service disruption potential]
- **Privilege Required:** [None/Low/High - authentication level needed]
- **CVSS v3.1:** [Score] ([Vector string])
## Details
[Technical details about the vulnerability]
**Affected Code:**
```php
// Path to affected file and vulnerable code
```
**Attack Vector:**
[How an attacker can exploit this]
## Proof of Concept
```bash
# Steps to reproduce
```
## Patch
[Suggested fix or approach]
## Affected Versions
- OpenSourcePOS X.Y.Z and earlier
## Credit
[Your GitHub username or preferred name]
```
---
**Thank you to all security researchers who have contributed to making Open Source Point of Sale more secure.** Your voluntary efforts help protect thousands of users worldwide and contribute to a safer, more trustworthy free and open-source software ecosystem. We deeply appreciate your responsible disclosure and the time you invest in improving our project.
If you've reported a vulnerability and would like to discuss CVE coordination or have questions about the process, please reach out to us at [jeroen@steganos.dev](mailto:jeroen@steganos.dev).

View File

@@ -58,9 +58,9 @@ class App extends BaseConfig
* Allowed Hostnames in the Site URL other than the hostname in the baseURL.
* If you want to accept multiple Hostnames, set this.
*
* E.g.,
* When your site URL ($baseURL) is 'http://example.com/', and your site
* also accepts 'http://media.example.com/' and 'http://accounts.example.com/':
* Or via environment variable (useful for Docker/Compose):
* ALLOWED_HOSTNAMES=example.com,www.example.com
*
* ['media.example.com', 'accounts.example.com']
*
* @var list<string>
@@ -286,7 +286,11 @@ class App extends BaseConfig
// Solution for CodeIgniter 4 limitation: arrays cannot be set from .env
// See: https://github.com/codeigniter4/CodeIgniter4/issues/7311
$envAllowedHostnames = getenv('app.allowedHostnames');
// Support both: app.allowedHostnames (from .env) and ALLOWED_HOSTNAMES (from environment/Docker)
$envAllowedHostnames = getenv('ALLOWED_HOSTNAMES');
if ($envAllowedHostnames === false || trim($envAllowedHostnames) === '') {
$envAllowedHostnames = getenv('app.allowedHostnames');
}
if ($envAllowedHostnames !== false && trim($envAllowedHostnames) !== '') {
$this->allowedHostnames = array_values(array_filter(
array_map('trim', explode(',', $envAllowedHostnames)),
@@ -327,7 +331,7 @@ class App extends BaseConfig
$errorMessage =
'Security: allowedHostnames is not configured. ' .
'Host header injection protection is disabled. ' .
'Set app.allowedHostnames in your .env file. ' .
'Set app.allowedHostnames in your .env file or ALLOWED_HOSTNAMES environment variable. ' .
'Example: app.allowedHostnames = "example.com,www.example.com" ' .
'Received Host: ' . $httpHost;

View File

@@ -106,4 +106,54 @@ class Encryption extends BaseConfig
* by CI3 Encryption default configuration.
*/
public string $cipher = 'AES-256-CTR';
/**
* Constructor - loads encryption key from fallback location if not set.
*
* This supports Docker/container environments where ROOTPATH/.env may be
* read-only or ephemeral. The fallback key file is stored in WRITEPATH/config/.
*/
public function __construct()
{
parent::__construct();
// If key not set from .env or environment, try WRITEPATH fallback
if (empty($this->key) || strlen($this->key) < 64) {
$fallbackKey = $this->loadKeyFromWritable();
if ($fallbackKey !== null) {
$this->key = $fallbackKey;
}
}
}
/**
* Loads encryption key from WRITEPATH/config/encryption.key.
*
* @return string|null The encryption key if found, null otherwise
*/
private function loadKeyFromWritable(): ?string
{
$keyFile = WRITEPATH . 'config' . DIRECTORY_SEPARATOR . 'encryption.key';
if (!file_exists($keyFile) || !is_readable($keyFile)) {
return null;
}
$content = file_get_contents($keyFile);
if ($content === false) {
return null;
}
$data = json_decode($content, true);
if (
!is_array($data)
|| !isset($data['key'])
|| !is_string($data['key'])
|| strlen($data['key']) < 64
) {
return null;
}
return $data['key'];
}
}

View File

@@ -5,6 +5,7 @@ namespace Config;
use App\Models\Appconfig;
use CodeIgniter\Cache\CacheInterface;
use CodeIgniter\Config\BaseConfig;
use Config\Database;
/**
* This class holds the configuration options stored from the database so that on launch those settings can be cached
@@ -13,7 +14,7 @@ use CodeIgniter\Config\BaseConfig;
*/
class OSPOS extends BaseConfig
{
public array $settings;
public array $settings = [];
public string $commit_sha1 = 'dev'; // TODO: Travis scripts need to be updated to replace this with the commit hash on build
private CacheInterface $cache;
@@ -33,25 +34,35 @@ class OSPOS extends BaseConfig
if ($cache) {
$this->settings = decode_array($cache);
} else {
try {
$appconfig = model(Appconfig::class);
foreach ($appconfig->get_all()->getResult() as $app_config) {
$this->settings[$app_config->key] = $app_config->value;
}
$this->cache->save('settings', encode_array($this->settings));
} catch (\Exception $e) {
// Database table doesn't exist yet (migrations haven't run)
// or database connection failed. Return empty settings to
// allow migration page to display. Catches mysqli_sql_exception
// which is not a subclass of DatabaseException.
$this->settings = [
'language' => 'english',
'language_code' => 'en',
'company' => 'Home'
];
}
return;
}
try {
$db = Database::connect();
if (!$db->tableExists('app_config')) {
$this->settings = $this->getDefaultSettings();
return;
}
$appconfig = model(Appconfig::class);
foreach ($appconfig->get_all()->getResult() as $app_config) {
$this->settings[$app_config->key] = $app_config->value;
}
$this->cache->save('settings', encode_array($this->settings));
} catch (\Exception $e) {
$this->settings = $this->getDefaultSettings();
}
}
private function getDefaultSettings(): array
{
return [
'language' => 'english',
'language_code' => 'en',
'company' => 'Home',
'barcode_type' => 'Code39'
];
}
/**
@@ -62,4 +73,4 @@ class OSPOS extends BaseConfig
$this->cache->delete('settings');
$this->set_settings();
}
}
}

View File

@@ -17,11 +17,9 @@ use App\Models\Enums\Rounding_mode;
use App\Models\Stock_location;
use App\Models\Tax;
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\Encryption\EncrypterInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Database;
use Config\OSPOS;
use Config\Services;
use DirectoryIterator;
use NumberFormatter;
use ReflectionException;
@@ -30,7 +28,6 @@ class Config extends Secure_Controller
{
protected $helpers = ['security'];
private BaseConnection $db;
private EncrypterInterface $encrypter;
private Barcode_lib $barcode_lib;
private Sale_lib $sale_lib;
private Receiving_lib $receiving_lib;
@@ -62,13 +59,6 @@ class Config extends Secure_Controller
$this->tax = model(Tax::class);
$this->config = config(OSPOS::class)->settings;
$this->db = Database::connect();
helper('security');
if (check_encryption()) {
$this->encrypter = Services::encrypter();
} else {
log_message('alert', 'Error preparing encryption key');
}
}
/**
@@ -256,25 +246,11 @@ class Config extends Secure_Controller
// Integrations Related fields
$data['mailchimp'] = [];
if (check_encryption()) { // TODO: Hungarian notation
if (!isset($this->encrypter)) {
helper('security');
$this->encrypter = Services::encrypter();
}
$data['mailchimp']['api_key'] = decrypt_value($this->config['mailchimp_api_key'] ?? null);
$data['mailchimp']['list_id'] = decrypt_value($this->config['mailchimp_list_id'] ?? null);
$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']['list_id'] = (isset($this->config['mailchimp_list_id']) && !empty($this->config['mailchimp_list_id']))
? $this->encrypter->decrypt($this->config['mailchimp_list_id'])
: '';
// Remove any backup of .env created by check_encryption()
if (check_encryption()) {
remove_backup();
} else {
$data['mailchimp']['api_key'] = '';
$data['mailchimp']['list_id'] = '';
}
$data['mailchimp']['lists'] = $this->_mailchimp();
@@ -512,15 +488,23 @@ class Config extends Secure_Controller
public function postSaveEmail(): ResponseInterface
{
$password = '';
$passwordInput = $this->request->getPost('smtp_pass');
if (check_encryption() && !empty($this->request->getPost('smtp_pass'))) {
$password = $this->encrypter->encrypt($this->request->getPost('smtp_pass'));
if (!empty($passwordInput)) {
$password = encrypt_value($passwordInput);
if (empty($password)) {
log_message('error', 'SMTP password encryption failed - credentials not saved');
return $this->response->setJSON([
'success' => false,
'message' => lang('Config.encryption_failed'),
]);
}
}
$protocol = $this->request->getPost('protocol');
$mailpath = $this->request->getPost('mailpath');
// Validate mailpath: required for sendmail, optional for others but must be safe if provided
$isMailpathRequired = ($protocol === 'sendmail');
$isMailpathProvided = !empty($mailpath);
$isMailpathValid = $isMailpathProvided && preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $mailpath);
@@ -528,7 +512,7 @@ class Config extends Secure_Controller
if (($isMailpathRequired && !$isMailpathProvided) || ($isMailpathProvided && !$isMailpathValid)) {
return $this->response->setJSON([
'success' => false,
'message' => lang('Config.mailpath_invalid')
'message' => lang('Config.mailpath_invalid'),
]);
}
@@ -540,7 +524,7 @@ class Config extends Secure_Controller
'smtp_pass' => $password,
'smtp_port' => $this->request->getPost('smtp_port', FILTER_SANITIZE_NUMBER_INT),
'smtp_timeout' => $this->request->getPost('smtp_timeout', FILTER_SANITIZE_NUMBER_INT),
'smtp_crypto' => $this->request->getPost('smtp_crypto')
'smtp_crypto' => $this->request->getPost('smtp_crypto'),
];
$success = $this->appconfig->batch_save($batch_save_data);
@@ -558,16 +542,25 @@ class Config extends Secure_Controller
public function postSaveMessage(): ResponseInterface
{
$password = '';
$passwordInput = $this->request->getPost('msg_pwd');
if (check_encryption() && !empty($this->request->getPost('msg_pwd'))) {
$password = $this->encrypter->encrypt($this->request->getPost('msg_pwd'));
if (!empty($passwordInput)) {
$password = encrypt_value($passwordInput);
if (empty($password)) {
log_message('error', 'SMS password encryption failed');
return $this->response->setJSON([
'success' => false,
'message' => lang('Config.encryption_failed'),
]);
}
}
$batch_save_data = [
'msg_msg' => $this->request->getPost('msg_msg'),
'msg_uid' => $this->request->getPost('msg_uid'),
'msg_pwd' => $password,
'msg_src' => $this->request->getPost('msg_src')
'msg_src' => $this->request->getPost('msg_src'),
];
$success = $this->appconfig->batch_save($batch_save_data);
@@ -626,15 +619,29 @@ class Config extends Secure_Controller
$api_key = '';
$list_id = '';
if (check_encryption()) {
$api_key_unencrypted = $this->request->getPost('mailchimp_api_key');
if (!empty($api_key_unencrypted)) {
$api_key = $this->encrypter->encrypt($api_key_unencrypted);
}
$api_key_input = $this->request->getPost('mailchimp_api_key');
if (!empty($api_key_input)) {
$api_key = encrypt_value($api_key_input);
if (empty($api_key)) {
log_message('error', 'Mailchimp API key encryption failed');
$list_id_unencrypted = $this->request->getPost('mailchimp_list_id');
if (!empty($list_id_unencrypted)) {
$list_id = $this->encrypter->encrypt($list_id_unencrypted);
return $this->response->setJSON([
'success' => false,
'message' => lang('Config.encryption_failed'),
]);
}
}
$list_id_input = $this->request->getPost('mailchimp_list_id');
if (!empty($list_id_input)) {
$list_id = encrypt_value($list_id_input);
if (empty($list_id)) {
log_message('error', 'Mailchimp list ID encryption failed');
return $this->response->setJSON([
'success' => false,
'message' => lang('Config.encryption_failed'),
]);
}
}
@@ -924,7 +931,9 @@ class Config extends Secure_Controller
public function postSaveReceipt(): ResponseInterface
{
$batch_save_data = [
'receipt_template' => $this->request->getPost('receipt_template'),
'receipt_template' => Sale_lib::isValidReceiptTemplate($this->request->getPost('receipt_template'))
? $this->request->getPost('receipt_template')
: 'receipt_default',
'receipt_font_size' => $this->request->getPost('receipt_font_size', FILTER_SANITIZE_NUMBER_INT),
'print_delay_autoreturn' => $this->request->getPost('print_delay_autoreturn', FILTER_SANITIZE_NUMBER_INT),
'email_receipt_check_behaviour' => $this->request->getPost('email_receipt_check_behaviour'),

View File

@@ -31,13 +31,7 @@ class Customers extends Persons
$this->tax_code = model(Tax_code::class);
$this->config = config(OSPOS::class)->settings;
$encrypter = Services::encrypter();
if (!empty($this->config['mailchimp_list_id'])) {
$this->_list_id = $encrypter->decrypt($this->config['mailchimp_list_id']);
} else {
$this->_list_id = '';
}
$this->_list_id = decrypt_value($this->config['mailchimp_list_id'] ?? null);
}
/**

View File

@@ -154,8 +154,23 @@ class Items extends Secure_Controller
{
helper('file');
$pic_filename = rawurldecode($pic_filename);
$file_extension = pathinfo($pic_filename, PATHINFO_EXTENSION);
// Security: Sanitize filename to prevent path traversal
// Use basename() to strip directory components and prevent '../' attacks
$pic_filename = basename(rawurldecode($pic_filename));
$file_extension = strtolower(pathinfo($pic_filename, PATHINFO_EXTENSION));
// Validate file extension against system-configured allowed image types
// Handle both legacy pipe-separated and current comma-separated formats
// Fallback to types that GD library can process for thumbnail generation
$allowed_types = $this->config['image_allowed_types'] ?? 'jpg,jpeg,gif,png,webp,bmp,tif,tiff';
$allowed_extensions = strpos($allowed_types, '|') !== false
? explode('|', $allowed_types)
: explode(',', $allowed_types);
if (!in_array($file_extension, $allowed_extensions, true)) {
return $this->response->setStatusCode(400)->setBody('Invalid file type');
}
$images = glob("./uploads/item_pics/$pic_filename");
$base_path = './uploads/item_pics/' . pathinfo($pic_filename, PATHINFO_FILENAME);
@@ -1040,14 +1055,20 @@ class Items extends Secure_Controller
});
if (!$isFailedRow && $this->item->save_value($itemData, $itemId)) {
$this->save_tax_data($row, $itemData);
$this->save_inventory_quantities($row, $itemData, $allowedStockLocations, $employeeId);
if (!$this->save_tax_data($row, $itemData)) {
$isFailedRow = true;
}
if (!$this->save_inventory_quantities($row, $itemData, $allowedStockLocations, $employeeId)) {
$isFailedRow = true;
}
$csvAttributeValues = $this->extractAttributeData($row);
$isFailedRow = !$this->attribute->saveCSVRowAttributeData($csvAttributeValues, $itemData, $attributeData);
if (!$this->attribute->saveCSVRowAttributeData($csvAttributeValues, $itemData, $attributeData)) {
$isFailedRow = true;
}
if ($isFailedRow) {
$failedRow = $key + 2;
$failCodes[] = $failedRow;
log_message('error', "CSV Item import failed on line $failedRow while saving attributes.");
log_message('error', "CSV Item import failed on line $failedRow while saving item.");
continue;
}
@@ -1237,13 +1258,15 @@ class Items extends Secure_Controller
* @param array $item_data
* @param array $allowed_locations
* @param int $employee_id
* @return bool Returns true on success, false on failure
* @throws ReflectionException
*/
private function save_inventory_quantities(array $row, array $item_data, array $allowed_locations, int $employee_id): void
private function save_inventory_quantities(array $row, array $item_data, array $allowed_locations, int $employee_id): bool
{
// Quantities & Inventory Section
$comment = lang('Items.inventory_CSV_import_quantity');
$is_update = (bool)$row['Id'];
$success = true;
foreach ($allowed_locations as $location_id => $location_name) {
$item_quantity_data = ['item_id' => $item_data['item_id'], 'location_id' => $location_id];
@@ -1257,20 +1280,22 @@ class Items extends Secure_Controller
if (!empty($row["location_$location_name"]) || $row["location_$location_name"] === '0') {
$item_quantity_data['quantity'] = $row["location_$location_name"];
$this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$success &= $this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$csv_data['trans_inventory'] = $row["location_$location_name"];
$this->inventory->insert($csv_data, false);
$success &= (bool)$this->inventory->insert($csv_data, false);
} elseif ($is_update) {
return;
continue;
} else {
$item_quantity_data['quantity'] = 0;
$this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$success &= $this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$csv_data['trans_inventory'] = 0;
$this->inventory->insert($csv_data, false);
$success &= (bool)$this->inventory->insert($csv_data, false);
}
}
return (bool)$success;
}
/**
@@ -1278,8 +1303,9 @@ class Items extends Secure_Controller
*
* @param array $row
* @param array $item_data
* @return bool Returns true on success, false on failure
*/
private function save_tax_data(array $row, array $item_data): void
private function save_tax_data(array $row, array $item_data): bool
{
$items_taxes_data = [];
@@ -1291,9 +1317,11 @@ class Items extends Secure_Controller
$items_taxes_data[] = ['name' => $row['Tax 2 Name'], 'percent' => $row['Tax 2 Percent']];
}
if (isset($items_taxes_data)) {
$this->item_taxes->save_value($items_taxes_data, $item_data['item_id']);
if (!empty($items_taxes_data)) {
return $this->item_taxes->save_value($items_taxes_data, $item_data['item_id']);
}
return true;
}
/**

View File

@@ -49,6 +49,13 @@ class Login extends BaseController
return view('login', $data);
}
if (!$data['is_latest'] || $data['is_new_install']) {
set_time_limit(3600);
$migration->setNamespace('App')->latest();
return redirect()->to('login');
}
$rules = ['username' => 'required|login_check[data]'];
$messages = [
'username' => [
@@ -62,13 +69,6 @@ class Login extends BaseController
return view('login', $data);
}
if (!$data['is_latest']) {
set_time_limit(3600);
$migration->setNamespace('App')->latest();
return redirect()->to('login');
}
}
return redirect()->to('home');
@@ -79,18 +79,18 @@ class Login extends BaseController
try {
$migration = new MY_Migration(config('Migrations'));
$migration->migrate_to_ci4();
set_time_limit(3600);
$migration->setNamespace('App')->latest();
return $this->response->setJSON([
'success' => true,
'message' => 'Migration completed successfully'
]);
} catch (\Exception $e) {
log_message('error', 'Migration failed: ' . $e->getMessage());
return $this->response->setJSON([
'success' => false,
'message' => 'Migration failed: ' . $e->getMessage()

View File

@@ -1246,13 +1246,15 @@ class Reports extends Secure_Controller
public function get_payment_type(): array
{
return [
'all' => lang('Common.none_selected_text'),
'cash' => lang('Sales.cash'),
'due' => lang('Sales.due'),
'check' => lang('Sales.check'),
'credit' => lang('Sales.credit'),
'debit' => lang('Sales.debit'),
'invoices' => lang('Sales.invoice')
'all' => lang('Common.none_selected_text'),
'cash' => lang('Sales.cash'),
'due' => lang('Sales.due'),
'check' => lang('Sales.check'),
'credit' => lang('Sales.credit'),
'debit' => lang('Sales.debit'),
'bank_transfer' => lang('Sales.bank_transfer'),
'wallet' => lang('Sales.wallet'),
'invoices' => lang('Sales.invoice')
];
}

View File

@@ -93,6 +93,8 @@ class Sales extends Secure_Controller
'only_check' => lang('Sales.check_filter'),
'only_creditcard' => lang('Sales.credit_filter'),
'only_debit' => lang('Sales.debit'),
'only_bank_transfer'=> lang('Sales.bank_transfer'),
'only_wallet' => lang('Sales.wallet'),
'only_invoices' => lang('Sales.invoice_filter'),
'selected_customer' => lang('Sales.selected_customer')
];
@@ -156,8 +158,10 @@ class Sales extends Secure_Controller
'selected_customer' => false,
'only_creditcard' => false,
'only_debit' => false,
'only_bank_transfer'=> false,
'only_wallet' => false,
'only_invoices' => $this->config['invoice_enable'] && $this->request->getGet('only_invoices', FILTER_SANITIZE_NUMBER_INT),
'is_valid_receipt' => $this->sale->is_valid_receipt($search)
'is_valid_receipt' => $this->sale->isValidReceipt($search)
];
// Check if any filter is set in the multiselect dropdown
@@ -194,7 +198,7 @@ class Sales extends Secure_Controller
? $this->request->getGet('term')
: null;
if ($this->sale_lib->get_mode() == 'return' && $this->sale->is_valid_receipt($receipt)) {
if ($this->sale_lib->get_mode() == 'return' && $this->sale->isValidReceipt($receipt)) {
// If a valid receipt or invoice was found the search term will be replaced with a receipt number (POS #)
$suggestions[] = $receipt;
}
@@ -521,7 +525,7 @@ class Sales extends Secure_Controller
$quantity = ($mode == 'return') ? -$quantity : $quantity;
$item_location = $this->sale_lib->get_sale_location();
if ($mode == 'return' && $this->sale->is_valid_receipt($item_id_or_number_or_item_kit_or_receipt)) {
if ($mode == 'return' && $this->sale->isValidReceipt($item_id_or_number_or_item_kit_or_receipt)) {
$this->sale_lib->return_entire_sale($item_id_or_number_or_item_kit_or_receipt);
} elseif ($this->item_kit->is_valid_item_kit($item_id_or_number_or_item_kit_or_receipt)) {
// Add kit item to order if one is assigned
@@ -904,6 +908,14 @@ class Sales extends Secure_Controller
return $this->_reload($data);
} else {
$data['barcode'] = $this->barcode_lib->generate_receipt_barcode($data['sale_id']);
// Validate receipt template to prevent path traversal
$receipt_template = $this->config['receipt_template'] ?? '';
if (!Sale_lib::isValidReceiptTemplate($receipt_template)) {
$receipt_template = 'receipt_default';
}
$data['receipt_template_view'] = $receipt_template;
$this->sale_lib->clear_all();
return view('sales/receipt', $data);
}
@@ -1159,6 +1171,13 @@ class Sales extends Secure_Controller
}
$data['invoice_view'] = $invoice_type;
// Validate receipt template to prevent path traversal
$receipt_template = $this->config['receipt_template'] ?? '';
if (!Sale_lib::isValidReceiptTemplate($receipt_template)) {
$receipt_template = 'receipt_default';
}
$data['receipt_template_view'] = $receipt_template;
return $data;
}

View File

@@ -2,6 +2,7 @@
namespace App\Database\Migrations;
use CodeIgniter\Database\Exceptions\DatabaseException;
use CodeIgniter\Database\Migration;
class Migration_Upgrade_To_3_1_1 extends Migration
@@ -17,7 +18,37 @@ class Migration_Upgrade_To_3_1_1 extends Migration
public function up(): void
{
helper('migration');
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.0.2_to_3.1.1.sql');
// MariaDB blocks CONVERT TO CHARACTER SET on tables with FK constraints.
// Drop all FKs across affected tables before running the SQL script, recreate after.
$fkColumns = [
['modules', 'module_id'],
['stock_locations', 'location_id'],
['permissions', 'permission_id'],
['people', 'person_id'],
['suppliers', 'supplier_id'],
['items', 'item_id'],
['item_kits', 'item_kit_id'],
['sales', 'sale_id'],
['receivings', 'receiving_id'],
['employees', 'employee_id'],
['customers', 'person_id'],
];
$constraints = [];
foreach ($fkColumns as [$table, $column]) {
foreach (dropAllForeignKeyConstraints($table, $column) as $c) {
$constraints[$c['constraintName']] = $c;
}
}
if (!execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.0.2_to_3.1.1.sql')) {
throw new DatabaseException('Migration script 3.0.2_to_3.1.1.sql failed. Check logs for details.');
}
$droppedTables = ['sales_suspended', 'sales_suspended_items', 'sales_suspended_items_taxes', 'sales_suspended_payments'];
$toRecreate = array_filter($constraints, fn($c) => !in_array($c['tableName'], $droppedTables, true));
recreateForeignKeyConstraints(array_values($toRecreate));
}
/**

View File

@@ -327,19 +327,6 @@ INSERT INTO `ospos_sales_items` (sale_id, item_id, description, serialnumber, li
INSERT INTO `ospos_sales_payments` (sale_id, payment_type, payment_amount) SELECT sale_id, payment_type, payment_amount FROM `ospos_sales_suspended_payments`;
INSERT INTO `ospos_sales_items_taxes` (sale_id, item_id, line, name, percent) SELECT sale_id, item_id, line, name, percent FROM `ospos_sales_suspended_items_taxes`;
ALTER TABLE `ospos_sales_suspended_payments` DROP FOREIGN KEY `ospos_sales_suspended_payments_ibfk_1`;
ALTER TABLE `ospos_sales_suspended_items_taxes` DROP FOREIGN KEY `ospos_sales_suspended_items_taxes_ibfk_1`;
ALTER TABLE `ospos_sales_suspended_items_taxes` DROP FOREIGN KEY `ospos_sales_suspended_items_taxes_ibfk_2`;
ALTER TABLE `ospos_sales_suspended_items` DROP FOREIGN KEY `ospos_sales_suspended_items_ibfk_1`;
ALTER TABLE `ospos_sales_suspended_items` DROP FOREIGN KEY `ospos_sales_suspended_items_ibfk_2`;
ALTER TABLE `ospos_sales_suspended_items` DROP FOREIGN KEY `ospos_sales_suspended_items_ibfk_3`;
ALTER TABLE `ospos_sales_suspended` DROP FOREIGN KEY `ospos_sales_suspended_ibfk_1`;
ALTER TABLE `ospos_sales_suspended` DROP FOREIGN KEY `ospos_sales_suspended_ibfk_2`;
ALTER TABLE `ospos_sales_suspended` DROP FOREIGN KEY `ospos_sales_suspended_ibfk_3`;
DROP TABLE `ospos_sales_suspended_payments`, `ospos_sales_suspended_items_taxes`, `ospos_sales_suspended_items`, `ospos_sales_suspended`;
--

View File

@@ -140,7 +140,7 @@ CREATE TABLE IF NOT EXISTS `ospos_expense_categories` (
`category_name` varchar(255) DEFAULT NULL,
`category_description` varchar(255) NOT NULL,
`deleted` int(1) NOT NULL DEFAULT '0'
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
-- Table structure for table `ospos_expenses`
@@ -154,7 +154,7 @@ CREATE TABLE IF NOT EXISTS `ospos_expenses` (
`description` varchar(255) NOT NULL,
`employee_id` int(10) NOT NULL,
`deleted` int(1) NOT NULL DEFAULT '0'
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
-- Indexes for table `ospos_expense_categories`

View File

@@ -75,7 +75,7 @@ CREATE TABLE `ospos_cash_up` (
`open_employee_id` int(10) NOT NULL,
`close_employee_id` int(10) NOT NULL,
`deleted` int(1) NOT NULL DEFAULT '0'
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
-- Indexes for table `ospos_cash_up`

View File

@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS `ospos_tax_codes` (
`state` varchar(255) NOT NULL DEFAULT '',
`deleted` int(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`tax_code_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
ALTER TABLE `ospos_customers`
ADD COLUMN `tax_id` varchar(32) NOT NULL DEFAULT '' AFTER `taxable`,
@@ -59,7 +59,7 @@ CREATE TABLE `ospos_sales_taxes` (
`rounding_code` tinyint(2) NOT NULL DEFAULT 0,
PRIMARY KEY (`sales_taxes_id`),
KEY `print_sequence` (`sale_id`,`print_sequence`,`tax_group`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
CREATE TABLE IF NOT EXISTS `ospos_tax_jurisdictions` (
`jurisdiction_id` int(11) NOT NULL AUTO_INCREMENT,
@@ -71,7 +71,7 @@ CREATE TABLE IF NOT EXISTS `ospos_tax_jurisdictions` (
`cascade_sequence` tinyint(2) NOT NULL DEFAULT 0,
`deleted` int(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`jurisdiction_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci AUTO_INCREMENT=1;
ALTER TABLE `ospos_suppliers`
ADD COLUMN `tax_id` varchar(32) DEFAULT NULL AFTER `account_number`;
@@ -89,7 +89,7 @@ CREATE TABLE IF NOT EXISTS `ospos_tax_rates` (
`tax_rate` decimal(15,4) NOT NULL DEFAULT 0.0000,
`tax_rounding_code` tinyint(2) NOT NULL DEFAULT 0,
PRIMARY KEY (`tax_rate_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
-- Add support for sales tax report

View File

@@ -12,7 +12,7 @@ CREATE TABLE `ospos_sales_payments` (
`reference_code` varchar(40) NOT NULL DEFAULT '',
PRIMARY KEY (`payment_id`),
KEY `payment_sale` (`sale_id`, `payment_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
INSERT INTO ospos_sales_payments (sale_id, payment_type, payment_amount, payment_user)
SELECT payments.sale_id, payments.payment_type, payments.payment_amount, sales.employee_id

View File

@@ -272,6 +272,9 @@ function get_payment_options(): array
$payments[lang('Sales.upi')] = lang('Sales.upi');
}
$payments[lang('Sales.bank_transfer')] = lang('Sales.bank_transfer');
$payments[lang('Sales.wallet')] = lang('Sales.wallet');
return $payments;
}

View File

@@ -172,6 +172,7 @@ function dropAllForeignKeyConstraints(string $table, string $column): array {
WHERE kcu.TABLE_SCHEMA = DATABASE()
AND ((kcu.REFERENCED_TABLE_NAME = '" . $db->getPrefix() . "$table' AND kcu.REFERENCED_COLUMN_NAME = '$column')
OR (kcu.TABLE_NAME = '" . $db->getPrefix() . "$table' AND kcu.COLUMN_NAME = '$column'))
AND rc.CONSTRAINT_NAME IS NOT NULL
");
$deletedConstraints = [];

View File

@@ -4,67 +4,227 @@ use CodeIgniter\Encryption\Encryption;
use Config\Services;
/**
* @return bool
* Checks and initializes encryption key.
*
* This function ensures a valid encryption key exists for the application.
* It tries multiple storage locations to support different deployment scenarios:
* 1. ROOTPATH/.env - Standard location for non-containerized deployments
* 2. WRITEPATH/config/encryption.key - Fallback for Docker/container environments where .env is read-only
*
* @return bool True if encryption key is available, false if key generation/persistence failed
*/
function check_encryption(): bool
{
$old_key = config('Encryption')->key;
if ((empty($old_key)) || (strlen($old_key) < 64)) {
$encryption = new Encryption();
$key = bin2hex($encryption->createKey());
config('Encryption')->key = $key;
// Key already exists and is valid (64+ hex chars = 32+ bytes)
if (!empty($old_key) && strlen($old_key) >= 64) {
return true;
}
$config_path = ROOTPATH . '.env';
$backup_path = WRITEPATH . '/backup/.env.bak';
$backup_folder = WRITEPATH . '/backup';
// Generate a new key
$encryption = new Encryption();
$key = bin2hex($encryption->createKey());
config('Encryption')->key = $key;
if (!file_exists($backup_folder)) {
@mkdir($backup_folder, 0750, true);
}
// Try to persist the key - attempt multiple locations
// Write both locations when possible. The writable copy is the durable one
// in containerized deployments where .env may be ephemeral.
$envPersisted = write_encryption_key_to_env($key, $old_key);
$writablePersisted = write_encryption_key_to_writable($key, $old_key);
$persisted = $envPersisted || $writablePersisted;
if (!file_exists($config_path)) {
$example_path = ROOTPATH . '.env.example';
if (file_exists($example_path)) {
@copy($example_path, $config_path);
} else {
@file_put_contents($config_path, "# OSPOS Configuration\n\n");
}
@chmod($config_path, 0640);
}
if ($persisted) {
log_message('info', 'Encryption key initialized successfully');
} else {
log_message('error', 'Failed to persist encryption key to any location. Encryption may not survive container restarts.');
}
if (file_exists($config_path)) {
@copy($config_path, $backup_path);
@chmod($backup_path, 0640);
@chmod($config_path, 0640);
return $persisted;
}
$config_file = file_get_contents($config_path);
/**
* Writes encryption key to ROOTPATH/.env file.
*
* @param string $key The new encryption key (hex-encoded)
* @param string|null $old_key The previous key to preserve for key rotation
*
* @return bool True if key was written successfully, false otherwise
*/
function write_encryption_key_to_env(string $key, ?string $old_key = null): bool
{
$config_path = ROOTPATH . '.env';
$backup_path = WRITEPATH . 'backup' . DIRECTORY_SEPARATOR . '.env.bak';
$backup_folder = WRITEPATH . 'backup';
if (strpos($config_file, 'encryption.key') !== false) {
$config_file = preg_replace("/(encryption\.key.*=.*)('.*')/", "$1'$key'", $config_file);
} else {
$config_file .= "\nencryption.key = '$key'\n";
}
if (!empty($old_key)) {
$old_line = "# encryption.key = '$old_key' REMOVE IF UNNEEDED\r\n";
$insertion_point = stripos($config_file, 'encryption.key');
if ($insertion_point !== false) {
$config_file = substr_replace($config_file, $old_line, $insertion_point, 0);
}
}
@file_put_contents($config_path, $config_file);
@chmod($config_path, 0640);
log_message('info', "Updated encryption key in $config_path");
// Ensure backup directory exists
if (!file_exists($backup_folder)) {
if (!@mkdir($backup_folder, 0750, true)) {
log_message('debug', 'Could not create backup directory');
}
}
// Create .env if it doesn't exist
if (!file_exists($config_path)) {
$example_path = ROOTPATH . '.env.example';
if (file_exists($example_path)) {
if (!@copy($example_path, $config_path)) {
log_message('debug', 'Could not copy .env.example to .env');
}
} else {
if (!@file_put_contents($config_path, "# OSPOS Configuration\n\n") !== false) {
log_message('debug', 'Could not create .env file');
}
}
@chmod($config_path, 0640);
}
// Check if .env is writable
if (!is_writable($config_path)) {
log_message('debug', '.env file is not writable');
return false;
}
// Backup existing .env
if (file_exists($config_path)) {
@copy($config_path, $backup_path);
@chmod($backup_path, 0640);
}
// Read current content
$config_file = file_get_contents($config_path);
if ($config_file === false) {
log_message('debug', 'Could not read .env file');
return false;
}
if (strpos($config_file, 'encryption.key') !== false) {
$config_file = preg_replace("/(encryption\.key.*=.*)(['\"])([^'\"]*)\\2/", "$1'$key'", $config_file);
} else {
$config_file .= "\nencryption.key = '$key'\n";
}
// Preserve old key for rotation if present
if (!empty($old_key)) {
$old_line = "# encryption.key = '$old_key' REMOVE IF UNNEEDED\r\n";
$insertion_point = stripos($config_file, 'encryption.key');
if ($insertion_point !== false) {
$config_file = substr_replace($config_file, $old_line, $insertion_point, 0);
}
}
// Write updated content
$result = file_put_contents($config_path, $config_file);
if ($result === false) {
log_message('debug', 'Could not write to .env file');
return false;
}
@chmod($config_path, 0640);
log_message('info', "Updated encryption key in $config_path");
return true;
}
/**
* Writes encryption key to WRITEPATH/config/encryption.key file.
*
* This is the fallback location for Docker/container environments where
* the ROOTPATH/.env file may be read-only or ephemeral.
*
* @param string $key The new encryption key (hex-encoded)
* @param string|null $old_key The previous key to preserve for key rotation
*
* @return bool True if key was written successfully, false otherwise
*/
function write_encryption_key_to_writable(string $key, ?string $old_key = null): bool
{
$key_file = WRITEPATH . 'config' . DIRECTORY_SEPARATOR . 'encryption.key';
$key_dir = dirname($key_file);
// Ensure directory exists
if (!is_dir($key_dir)) {
if (!@mkdir($key_dir, 0750, true)) {
log_message('error', 'Could not create config directory: ' . $key_dir);
return false;
}
}
// Check if directory is writable
if (!is_writable($key_dir)) {
log_message('error', 'Config directory is not writable: ' . $key_dir);
return false;
}
// Build key data structure
$data = [
'key' => $key,
'previous_keys' => [],
'generated_at' => date('c'),
'generated_by' => 'check_encryption()',
];
if (!empty($old_key)) {
$data['previous_keys'][] = $old_key;
}
// Write key file
$content = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$result = file_put_contents($key_file, $content);
if ($result === false) {
log_message('error', 'Could not write encryption key file');
return false;
}
// Set restrictive permissions
@chmod($key_file, 0640);
log_message('info', "Stored encryption key in $key_file");
return true;
}
/**
* Loads encryption key from WRITEPATH/config/encryption.key file.
*
* This is the fallback key loader for Docker/container environments.
*
* @return string|null The encryption key if found, null otherwise
*/
function load_encryption_key_from_writable(): ?string
{
$key_file = WRITEPATH . 'config' . DIRECTORY_SEPARATOR . 'encryption.key';
if (!file_exists($key_file)) {
return null;
}
if (!is_readable($key_file)) {
log_message('error', 'Encryption key file exists but is not readable: ' . $key_file);
return null;
}
$content = file_get_contents($key_file);
if ($content === false) {
log_message('error', 'Could not read encryption key file');
return null;
}
$data = json_decode($content, true);
if (!is_array($data) || empty($data['key'])) {
log_message('error', 'Encryption key file has invalid format');
return null;
}
log_message('info', 'Loaded encryption key from WRITEPATH config');
return $data['key'];
}
/**
* Restores .env from backup (used by migration rollback).
*
* @return void
*/
function abort_encryption_conversion(): void
@@ -83,14 +243,77 @@ function abort_encryption_conversion(): void
}
/**
* Removes backup file (used after successful migration).
*
* @return void
*/
function remove_backup(): void
{
$backup_path = WRITEPATH . '/backup/.env.bak';
if (!file_exists($backup_path)) {
return;
if (file_exists($backup_path)) {
unlink($backup_path);
}
@unlink($backup_path);
log_message('info', "Removed $backup_path");
}
/**
* Decrypts an encrypted value with proper error handling.
*
* This function provides a consistent decryption pattern across the codebase,
* handling cases where encryption key may not be available or decryption fails.
*
* @param string|null $encrypted_value The encrypted value to decrypt
* @param string $default Default value to return if decryption fails
*
* @return string The decrypted value, or default if decryption fails
*/
function decrypt_value(?string $encrypted_value, string $default = ''): string
{
if (empty($encrypted_value)) {
return $default;
}
if (!check_encryption()) {
log_message('warning', 'Cannot decrypt value: encryption key not available');
return $default;
}
try {
$encrypter = Services::encrypter();
return $encrypter->decrypt($encrypted_value);
} catch (\CodeIgniter\Encryption\Exceptions\EncryptionException $e) {
log_message('error', 'Decryption failed: ' . $e->getMessage());
return $default;
}
}
/**
* Encrypts a value with proper error handling.
*
* This function provides a consistent encryption pattern across the codebase,
* handling cases where encryption key may not be available.
*
* @param string|null $value The value to encrypt
* @param bool $require If true, return empty string on failure instead of plaintext fallback
*
* @return string The encrypted value, or empty string if encryption fails when required
*/
function encrypt_value(?string $value, bool $require = true): string
{
if ($value === null || $value === '') {
return '';
}
if (!check_encryption()) {
log_message('error', 'Cannot encrypt value: encryption key not available');
return $require ? '' : $value;
}
try {
$encrypter = Services::encrypter();
return $encrypter->encrypt($value);
} catch (\CodeIgniter\Encryption\Exceptions\EncryptionException $e) {
log_message('error', 'Encryption failed: ' . $e->getMessage());
return $require ? '' : $value;
}
}

View File

@@ -9,6 +9,7 @@ return [
"amount_due" => "Amount Due",
"amount_tendered" => "Amount Tendered",
"authorized_signature" => "Authorised Signature",
"bank_transfer" => "Bank Transfer",
"cancel_sale" => "Cancel",
"cash" => "Cash",
"cash_1" => "",
@@ -223,6 +224,7 @@ return [
"update" => "Update",
"upi" => "UPI",
"visa" => "",
"wallet" => "Wallet",
"wholesale" => "",
"work_order" => "Work Order",
"work_order_number" => "Work Order Number",

View File

@@ -122,6 +122,7 @@ return [
"email_smtp_port" => "SMTP Port",
"email_smtp_timeout" => "SMTP Timeout (s)",
"email_smtp_user" => "SMTP Username",
"encryption_failed" => "Failed to encrypt data. Please check encryption configuration.",
"enable_avatar" => "",
"enable_avatar_tooltip" => "",
"enable_dropdown_tooltip" => "",

View File

@@ -9,6 +9,7 @@ return [
"amount_due" => "Amount Due",
"amount_tendered" => "Amount Tendered",
"authorized_signature" => "Authorized Signature",
"bank_transfer" => "Bank Transfer",
"cancel_sale" => "Cancel",
"cash" => "Cash",
"cash_1" => "",
@@ -223,6 +224,7 @@ return [
"update" => "Update",
"upi" => "UPI",
"visa" => "",
"wallet" => "Wallet",
"wholesale" => "",
"work_order" => "Work Order",
"work_order_number" => "Work Order Number",

View File

@@ -9,6 +9,7 @@ return [
"amount_due" => "Monto Adeudado",
"amount_tendered" => "Cantidad Recibida",
"authorized_signature" => "Firma Autorizada",
"bank_transfer" => "Transferencia Bancaria",
"cancel_sale" => "Cancelar Venta",
"cash" => "Efectivo",
"cash_1" => "1",
@@ -222,6 +223,7 @@ return [
"update" => "Editar",
"upi" => "PIN UPI",
"visa" => "Tarjeta Visa",
"wallet" => "Monedero",
"wholesale" => "Precio al por mayor",
"work_order" => "Orden trabajo",
"work_order_number" => "Numero Orden Trabajo",

View File

@@ -9,6 +9,7 @@ return [
"amount_due" => "Monto de adeudo",
"amount_tendered" => "Cantidad Recibida",
"authorized_signature" => "Firma Autorizada",
"bank_transfer" => "Transferencia Bancaria",
"cancel_sale" => "Cancelar",
"cash" => "Efectivo",
"cash_1" => "",
@@ -222,6 +223,7 @@ return [
"update" => "Actualizar",
"upi" => "UPI",
"visa" => "",
"wallet" => "Monedero",
"wholesale" => "",
"work_order" => "Orden de trabajo",
"work_order_number" => "Número de orden de trabajo",

View File

@@ -9,6 +9,7 @@ return [
"amount_due" => "Montant à Payer",
"amount_tendered" => "Montant Présenté",
"authorized_signature" => "Signature autorisée",
"bank_transfer" => "Virement Bancaire",
"cancel_sale" => "Annuler la Vente",
"cash" => "Espèce",
"cash_1" => "",
@@ -222,6 +223,7 @@ return [
"update" => "Éditer",
"upi" => "UPI",
"visa" => "",
"wallet" => "Portefeuille",
"wholesale" => "",
"work_order" => "Commande de travail",
"work_order_number" => "Numéro de commande",

View File

@@ -3,11 +3,7 @@
namespace app\Libraries;
use CodeIgniter\Email\Email;
use CodeIgniter\Encryption\Encryption;
use CodeIgniter\Encryption\EncrypterInterface;
use CodeIgniter\Encryption\Exceptions\EncryptionException;
use Config\OSPOS;
use Config\Services;
/**
@@ -26,19 +22,7 @@ class Email_lib
$this->email = new Email();
$this->config = config(OSPOS::class)->settings;
$encrypter = Services::encrypter();
$smtp_pass = $this->config['smtp_pass'];
if (!empty($smtp_pass) && check_encryption()) {
try {
$smtp_pass = $encrypter->decrypt($smtp_pass);
} catch (\EncryptionException $e) {
// Decryption failed, use the original value
log_message('error', 'SMTP password decryption failed: ' . $e->getMessage());
$smtp_pass = '';
}
}
$smtp_pass = decrypt_value($this->config['smtp_pass'] ?? null);
$email_config = [
'mailType' => 'html',
@@ -51,7 +35,7 @@ class Email_lib
'SMTPPass' => $smtp_pass,
'SMTPPort' => (int)$this->config['smtp_port'],
'SMTPTimeout' => (int)$this->config['smtp_timeout'],
'SMTPCrypto' => $this->config['smtp_crypto']
'SMTPCrypto' => $this->config['smtp_crypto'],
];
$this->email->initialize($email_config);
}

View File

@@ -25,7 +25,7 @@ class MY_Migration extends MigrationRunner
public function get_latest_migration(): int
{
$migrations = $this->findMigrations();
return basename(end($migrations)->version);
return (int) basename(end($migrations)->version);
}
/**
@@ -41,7 +41,7 @@ class MY_Migration extends MigrationRunner
$builder = $db->table('migrations');
$builder->select('version')->orderBy('version', 'DESC')->limit(1);
$result = $builder->get()->getRow();
return $result ? $result->version : 0;
return $result ? (int) $result->version : 0;
}
} catch (\Exception $e) {
// Database not available yet (e.g. fresh install before schema).

View File

@@ -2,9 +2,7 @@
namespace app\Libraries;
use CodeIgniter\Encryption\EncrypterInterface;
use Config\OSPOS;
use Config\Services;
/**
* MailChimp API v3 REST client Connector
@@ -14,8 +12,6 @@ use Config\Services;
* Inspired by the work of:
* - Rajitha Bandara: https://github.com/rajitha-bandara/ci-mailchimp-v3-rest-client
* - Stefan Ashwell: https://github.com/stef686/codeigniter-mailchimp-api-v3
*
* @property encrypterinterface encrypter
*/
class MailchimpConnector
{
@@ -40,23 +36,19 @@ class MailchimpConnector
{
$config = config(OSPOS::class)->settings;
$encrypter = Services::encrypter();
$mailchimp_api_key = (isset($this->config['mailchimp_api_key']) && !empty($this->config['mailchimp_api_key']))
? $this->config['mailchimp_api_key']
: '';
$mailchimp_api_key = $config['mailchimp_api_key'] ?? '';
if (!empty($mailchimp_api_key)) {
$this->_api_key = empty($api_key)
? $encrypter->decrypt($mailchimp_api_key) // TODO: Hungarian notation
: $api_key; // TODO: Hungarian notation
? decrypt_value($mailchimp_api_key)
: $api_key;
}
if (!empty($this->_api_key)) { // TODO: Hungarian notation
if (!empty($this->_api_key)) {
// Replace <dc> with correct datacenter obtained from the last part of the api key
$strings = explode('-', $this->_api_key); // TODO: Hungarian notation
$strings = explode('-', $this->_api_key);
if (is_array($strings) && !empty($strings[1])) {
$this->_api_endpoint = str_replace('<dc>', $strings[1], $this->_api_endpoint); // TODO: Hungarian notation
$this->_api_endpoint = str_replace('<dc>', $strings[1], $this->_api_endpoint);
}
}
}

View File

@@ -108,6 +108,11 @@ class Sale_lib
'custom_tax_invoice'
];
private const ALLOWED_RECEIPT_TEMPLATES = [
'receipt_default',
'receipt_short'
];
public function get_invoice_type_options(): array
{
$invoice_types = [];
@@ -161,6 +166,11 @@ class Sale_lib
return in_array($invoice_type, self::ALLOWED_INVOICE_TYPES, true);
}
public static function isValidReceiptTemplate(string $receipt_template): bool
{
return in_array($receipt_template, self::ALLOWED_RECEIPT_TEMPLATES, true);
}
/**
* @return array
*/

View File

@@ -2,10 +2,7 @@
namespace app\Libraries;
use CodeIgniter\Encryption\Encryption;
use CodeIgniter\Encryption\EncrypterInterface;
use Config\OSPOS;
use Config\Services;
/**
@@ -24,12 +21,7 @@ class Sms_lib
{
$config = config(OSPOS::class)->settings;
$encrypter = Services::encrypter();
$password = $config['msg_pwd'];
if (!empty($password)) {
$password = $encrypter->decrypt($password);
}
$password = decrypt_value($config['msg_pwd'] ?? null);
$username = $config['msg_uid'];
$originator = $config['msg_src'];

View File

@@ -601,6 +601,10 @@ class Attribute extends Model
*/
public function saveAttributeLink(int $itemId, int $definitionId, int $attributeId): bool
{
if ($attributeId <= 0) {
return false;
}
$normalizedItemId = empty($itemId) ? null : $itemId;
$normalizedAttributeId = empty($attributeId) ? null : $attributeId;

View File

@@ -294,7 +294,9 @@ class Receiving extends Model
lang('Sales.check') => lang('Sales.check'),
lang('Sales.debit') => lang('Sales.debit'),
lang('Sales.credit') => lang('Sales.credit'),
lang('Sales.due') => lang('Sales.due')
lang('Sales.due') => lang('Sales.due'),
lang('Sales.bank_transfer') => lang('Sales.bank_transfer'),
lang('Sales.wallet') => lang('Sales.wallet')
];
}

View File

@@ -33,14 +33,16 @@ class Summary_sales_taxes extends Summary_report
* @param object $builder
* @return void
*/
protected function _where(array $inputs, object &$builder): void // TODO: hungarian notation
protected function _where(array $inputs, object &$builder): void
{
$builder->where('sales.sale_status', COMPLETED);
if (empty($this->config['date_or_time_format'])) { // TODO: Duplicated code
$builder->where('DATE(sales.sale_time) BETWEEN ' . $this->db->escape($inputs['start_date']) . ' AND ' . $this->db->escape($inputs['end_date']));
if (empty($this->config['date_or_time_format'])) {
$builder->where('DATE(sales.sale_time) >=', $inputs['start_date']);
$builder->where('DATE(sales.sale_time) <=', $inputs['end_date']);
} else {
$builder->where('sales.sale_time BETWEEN ' . $this->db->escape(rawurldecode($inputs['start_date'])) . ' AND ' . $this->db->escape(rawurldecode($inputs['end_date'])));
$builder->where('sales.sale_time >=', $inputs['start_date']);
$builder->where('sales.sale_time <=', $inputs['end_date']);
}
}
@@ -53,9 +55,11 @@ class Summary_sales_taxes extends Summary_report
$builder = $this->db->table('sales_taxes');
if (empty($this->config['date_or_time_format'])) {
$builder->where('DATE(sale_time) BETWEEN ' . $inputs['start_date'] . ' AND ' . $inputs['end_date']);
$builder->where('DATE(sale_time) >=', $inputs['start_date']);
$builder->where('DATE(sale_time) <=', $inputs['end_date']);
} else {
$builder->where('sale_time BETWEEN ' . $this->db->escape(rawurldecode($inputs['start_date'])) . ' AND ' . $this->db->escape(rawurldecode($inputs['end_date'])));
$builder->where('sale_time >=', $inputs['start_date']);
$builder->where('sale_time <=', $inputs['end_date']);
}
$builder->select('reporting_authority, jurisdiction_name, tax_category, tax_rate, SUM(sale_tax_amount) AS tax');

View File

@@ -277,6 +277,14 @@ class Sale extends Model
$builder->like('payment_type', lang('Sales.debit'));
}
if ($filters['only_bank_transfer']) {
$builder->like('payment_type', lang('Sales.bank_transfer'));
}
if ($filters['only_wallet']) {
$builder->like('payment_type', lang('Sales.wallet'));
}
$builder->groupBy('payment_type');
$payments = $builder->get()->getResultArray();
@@ -319,7 +327,7 @@ class Sale extends Model
{
$suggestions = [];
if (!$this->is_valid_receipt($search)) {
if (!$this->isValidReceipt($search)) {
$builder = $this->db->table('sales');
$builder->distinct()->select('first_name, last_name');
$builder->join('people', 'people.person_id = sales.customer_id');
@@ -400,21 +408,21 @@ class Sale extends Model
/**
* Checks if valid receipt
*/
public function is_valid_receipt(string|null &$receipt_sale_id): bool // TODO: like the others, maybe this should be an array rather than a delimited string... either that or the parameter name needs to be changed. $receipt_sale_id implies that it's an int.
public function isValidReceipt(string|null &$receiptSaleId): bool // TODO: like the others, maybe this should be an array rather than a delimited string... either that or the parameter name needs to be changed. $receipt_sale_id implies that it's an int.
{
$config = config(OSPOS::class)->settings;
if (!empty($receipt_sale_id)) {
if (!empty($receiptSaleId)) {
// POS #
$pieces = explode(' ', $receipt_sale_id);
$pieces = explode(' ', trim($receiptSaleId));
if (count($pieces) == 2 && preg_match('/(POS)/i', $pieces[0])) {
return $this->exists($pieces[1]);
if (count($pieces) == 2 && strtoupper($pieces[0]) === 'POS' && ctype_digit($pieces[1])) {
return $this->exists((int)$pieces[1]);
} elseif ($config['invoice_enable']) {
$sale_info = $this->get_sale_by_invoice_number($receipt_sale_id);
$saleInfo = $this->get_sale_by_invoice_number($receiptSaleId);
if ($sale_info->getNumRows() > 0) {
$receipt_sale_id = 'POS ' . $sale_info->getRow()->sale_id;
if ($saleInfo->getNumRows() > 0) {
$receiptSaleId = 'POS ' . $saleInfo->getRow()->sale_id;
return true;
}
@@ -1509,5 +1517,13 @@ class Sale extends Model
if ($filters['only_check']) {
$builder->like('payments.payment_type', lang('Sales.check'));
}
if ($filters['only_bank_transfer']) {
$builder->like('payments.payment_type', lang('Sales.bank_transfer'));
}
if ($filters['only_wallet']) {
$builder->like('payments.payment_type', lang('Sales.wallet'));
}
}
}

View File

@@ -2,11 +2,14 @@
/**
* @var int $sale_id_num
* @var bool $print_after_sale
* @var string $receipt_template_view
* @var array $config
*/
use App\Models\Employee;
$template = $receipt_template_view ?? 'receipt_default';
?>
<?= view('partial/header') ?>
@@ -61,6 +64,6 @@ if (isset($error_message)) {
<?php endif; ?>
</div>
<?= view('sales/' . $config['receipt_template']) ?>
<?= view('sales/' . $template) ?>
<?= view('partial/footer') ?>

View File

@@ -46,6 +46,7 @@ services:
- .:/app
environment:
- CI_ENVIRONMENT=development
- ALLOWED_HOSTNAMES=localhost
- MYSQL_USERNAME=admin
- MYSQL_PASSWORD=pointofsale
- MYSQL_DB_NAME=ospos

View File

@@ -16,6 +16,7 @@ services:
- logs:/app/writable/logs
environment:
- CI_ENVIRONMENT=production
- ALLOWED_HOSTNAMES=localhost
- FORCE_HTTPS=false
- PHP_TIMEZONE=UTC
- MYSQL_USERNAME=admin

View File

@@ -18,6 +18,7 @@ class AppTest extends CIUnitTestCase
// Clean up environment
putenv('CI_ENVIRONMENT');
putenv('app.allowedHostnames');
putenv('ALLOWED_HOSTNAMES');
unset($_SERVER['HTTP_HOST']);
}
@@ -281,4 +282,106 @@ class AppTest extends CIUnitTestCase
putenv('app.allowedHostnames');
putenv('CI_ENVIRONMENT');
}
public function testAllowedHostnamesEnvVarParsedAsCommaSeparated(): void
{
// Set ALLOWED_HOSTNAMES environment variable
putenv('ALLOWED_HOSTNAMES=example.com,www.example.com,demo.example.com');
$_SERVER['HTTP_HOST'] = 'www.example.com';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['HTTPS'] = null;
$app = new App();
// Constructor should parse comma-separated values
$this->assertEquals(['example.com', 'www.example.com', 'demo.example.com'], $app->allowedHostnames);
$this->assertStringContainsString('www.example.com', $app->baseURL);
// Clean up
putenv('ALLOWED_HOSTNAMES');
}
public function testAllowedHostnamesEnvVarTakesPrecedenceOverDotEnv(): void
{
// Set both environment variables
putenv('ALLOWED_HOSTNAMES=allowed1.com,allowed2.com');
putenv('app.allowedHostnames=dotenv1.com,dotenv2.com');
$_SERVER['HTTP_HOST'] = 'allowed1.com';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['HTTPS'] = null;
$app = new App();
// ALLOWED_HOSTNAMES should take precedence
$this->assertEquals(['allowed1.com', 'allowed2.com'], $app->allowedHostnames);
$this->assertStringContainsString('allowed1.com', $app->baseURL);
// Clean up
putenv('ALLOWED_HOSTNAMES');
putenv('app.allowedHostnames');
}
public function testAllowedHostnamesEnvVarFallsBackToDotEnv(): void
{
// Only set app.allowedHostnames, not ALLOWED_HOSTNAMES
putenv('app.allowedHostnames=dotenv1.com,dotenv2.com');
$_SERVER['HTTP_HOST'] = 'dotenv1.com';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['HTTPS'] = null;
$app = new App();
// Should fall back to app.allowedHostnames
$this->assertEquals(['dotenv1.com', 'dotenv2.com'], $app->allowedHostnames);
$this->assertStringContainsString('dotenv1.com', $app->baseURL);
// Clean up
putenv('app.allowedHostnames');
}
public function testAllowedHostnamesEnvVarTrimmedWhitespace(): void
{
// Set environment variable with whitespace
putenv('ALLOWED_HOSTNAMES= example.com , www.example.com , demo.example.com ');
$_SERVER['HTTP_HOST'] = 'example.com';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['HTTPS'] = null;
$app = new App();
// Values should be trimmed
$this->assertEquals(['example.com', 'www.example.com', 'demo.example.com'], $app->allowedHostnames);
// Clean up
putenv('ALLOWED_HOSTNAMES');
}
public function testAllowedHostnamesEnvVarFiltersEmptyEntries(): void
{
// Trailing comma should not produce empty entry
putenv('ALLOWED_HOSTNAMES=example.com,');
$_SERVER['HTTP_HOST'] = 'example.com';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['HTTPS'] = null;
$app = new App();
$this->assertEquals(['example.com'], $app->allowedHostnames);
// Clean up
putenv('ALLOWED_HOSTNAMES');
// Whitespace-only entry should be filtered
putenv('ALLOWED_HOSTNAMES=example.com, ,www.example.com');
$_SERVER['HTTP_HOST'] = 'example.com';
$app = new App();
$this->assertEquals(['example.com', 'www.example.com'], $app->allowedHostnames);
// Clean up
putenv('ALLOWED_HOSTNAMES');
}
}