Compare commits

..

6 Commits

Author SHA1 Message Date
Ollama
5827f60045 fix: Address remaining CodeRabbit review comments
- Fix invalid jq string filter syntax (missing quotes around interpolation)
- Add environment validation job in deploy.yml for workflow_call input
- Add fork detection guard in deploy-pr.yml to prevent fork PR deployments

Fixes:
- deploy.yml:183-184 - jq filter syntax error
- deploy.yml:31 - unvalidated environment input in reusable workflow
- deploy-pr.yml:5 - fork PR deployments blocked by pull_request_review restrictions
- deploy-pr.yml:168-200 - jq filter syntax errors
2026-05-15 09:57:03 +02:00
Ollama
2acfb4773e fix(docker): enable .htaccess and set proper file permissions
- Enable AllowOverride in Apache config so .htaccess rewrite rules work
- Create missing item_pics upload directory with correct ownership
- Restrict .env file permissions to 640 (owner read/write, group read)
- Merged sed into existing RUN layer to minimize image size
2026-05-12 19:48:23 +02:00
Ollama
832b0e686f fix: Address CodeRabbit review comments
Security fixes:
- Use jq for JSON payload construction (prevents script injection)
- Add HMAC-SHA256 signature verification for webhook security
- Move untrusted inputs to env: blocks instead of inline interpolation

Robustness fixes:
- Add curl timeouts (--connect-timeout 10, --max-time 120)
- Fail when DEPLOY_WEBHOOK_URL is missing (was incorrectly succeeding)
- Add set -euo pipefail for error handling
- Fix required_contexts JSON array syntax (-F required_contexts[])
- Add deployment: false to prevent duplicate deployment records

Workflow improvements:
- Add concurrency groups to serialize same-environment deployments
- Remove unused skip_approval input
- Fix workflow_call inputs (removed required: true where default exists)
- Use vars.DEPLOY_URL for configurable environment URLs
2026-05-12 17:49:56 +02:00
Ollama
c26146c22b feat: Add PR-triggered staging deployment workflow
- deploy.yml: Add workflow_call for reuse from other workflows
- deploy-pr.yml: Auto-deploy to staging when PR is approved
- Uses GitHub environment protection for approval gates
- Posts deployment status as PR comment
- Image tag format: pr-{number}-{short-sha}
2026-05-12 17:47:36 +02:00
Ollama
e67f6bb290 fix: Update deploy webhook to match Docker Hub payload format
- Send payload matching Docker Hub webhook structure
- Include push_data.tag and repository.repo_name fields
- Token authentication via query string (?token=SECRET)
- Add optional DOCKER_REPO_NAME secret for custom repo
- Preserve GitHub deployment info in github_deployment field
2026-05-12 17:47:36 +02:00
Ollama
9f8eef96a7 feat: Add deployment workflow with approval gates
Adds manual deployment workflow triggered via GitHub Actions UI.
Supports production and staging environments with optional approval
gates configured via GitHub environment protection rules.

Workflow creates GitHub deployment records and calls an external
webhook to trigger the actual deployment.
2026-05-12 17:47:36 +02:00
75 changed files with 508 additions and 691 deletions

View File

@@ -16,9 +16,6 @@ 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,7 +123,6 @@ jobs:
.
!.git
!node_modules
include-hidden-files: true
retention-days: 1
docker:

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

@@ -0,0 +1,204 @@
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:
deploy-staging:
name: Deploy to staging
runs-on: ubuntu-latest
if: >
github.event.review.state == 'approved' &&
github.event.pull_request.head.repo.full_name == github.repository
environment:
name: staging
url: ${{ vars.DEPLOY_URL || 'https://dev.opensourcepos.org' }}
deployment: false
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"
- name: Create GitHub Deployment
id: deployment
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REF_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
DEPLOYMENT_ID=$(gh api "repos/${GITHUB_REPOSITORY}/deployments" \
-X POST \
-f ref="${REF_SHA}" \
-f environment="staging" \
-f description="Deploy PR #${PR_NUMBER} to staging" \
-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 }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
gh api "repos/${GITHUB_REPOSITORY}/deployments/${{ steps.deployment.outputs.deployment_id }}/statuses" \
-X POST \
-f state="in_progress" \
-f description="Deploying PR #${PR_NUMBER}..." \
-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: ${{ steps.image.outputs.tag }}
REF_SHA: ${{ github.event.pull_request.head.sha }}
DEPLOYMENT_ID: ${{ steps.deployment.outputs.deployment_id }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if [ -z "$DEPLOY_WEBHOOK_URL" ]; then
echo "::error::DEPLOY_WEBHOOK_URL secret is not configured"
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)
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}
}')
echo "Sending webhook..."
echo "Image: ${IMAGE_TAG}"
echo "PR: #${PR_NUMBER}"
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")
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: ${{ steps.image.outputs.tag }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
STATE="${{ steps.webhook.outputs.status }}"
if [ "$STATE" = "success" ]; then
DESCRIPTION=$(jq -nr --arg tag "$IMAGE_TAG" --arg pr "$PR_NUMBER" \
'"Deployed PR #\($pr) (\($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="Staging deployment failed"
exit 1
fi
- name: Comment deployment status
if: always()
env:
GH_TOKEN: ${{ github.token }}
IMAGE_TAG: ${{ steps.image.outputs.tag }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REF_SHA: ${{ github.event.pull_request.head.sha }}
STATUS: ${{ steps.webhook.outputs.status }}
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"

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

@@ -0,0 +1,214 @@
name: Deploy
on:
workflow_dispatch:
inputs:
image_tag:
description: 'Docker image tag to deploy (e.g., v3.4.0, latest)'
required: true
default: 'latest'
environment:
description: 'Target environment'
required: true
type: choice
options:
- production
- staging
default: 'production'
workflow_call:
inputs:
image_tag:
description: 'Docker image tag to deploy'
type: string
default: 'latest'
environment:
description: 'Target environment'
type: string
default: 'staging'
sha:
description: 'Git commit SHA to deploy'
required: true
type: string
concurrency:
group: deploy-${{ inputs.environment }}
cancel-in-progress: false
permissions:
contents: read
deployments: write
jobs:
validate-inputs:
name: Validate deployment inputs
runs-on: ubuntu-latest
steps:
- name: Validate environment
env:
TARGET_ENV: ${{ inputs.environment }}
run: |
set -euo pipefail
case "$TARGET_ENV" in
production|staging) ;;
*)
echo "::error::Invalid environment '$TARGET_ENV'. Expected 'production' or 'staging'."
exit 1
;;
esac
deploy:
name: Deploy to ${{ inputs.environment }}
needs: validate-inputs
runs-on: ubuntu-latest
environment:
name: ${{ inputs.environment }}
url: ${{ vars.DEPLOY_URL || (inputs.environment == 'production' && 'https://demo.opensourcepos.org' || 'https://dev.opensourcepos.org') }}
deployment: false
steps:
- name: Create GitHub Deployment
id: deployment
env:
GH_TOKEN: ${{ github.token }}
IMAGE_TAG: ${{ inputs.image_tag }}
TARGET_ENV: ${{ inputs.environment }}
REF_SHA: ${{ inputs.sha || github.sha }}
run: |
set -euo pipefail
DEPLOYMENT_ID=$(gh api "repos/${GITHUB_REPOSITORY}/deployments" \
-X POST \
-f ref="${REF_SHA}" \
-f environment="${TARGET_ENV}" \
-f description="Deploy image ${IMAGE_TAG}" \
-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 }}
TARGET_ENV: ${{ inputs.environment }}
REF_SHA: ${{ inputs.sha || github.sha }}
DEPLOYMENT_ID: ${{ steps.deployment.outputs.deployment_id }}
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)
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 environment "$TARGET_ENV" \
--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: $environment, repository: $repository, sha: $sha, run_id: $run_id, actor: $actor}
}')
echo "Sending webhook..."
echo "Image: ${IMAGE_TAG}"
echo "Environment: ${TARGET_ENV}"
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 }}
TARGET_ENV: ${{ inputs.environment }}
run: |
set -euo pipefail
STATE="${{ steps.webhook.outputs.status }}"
if [ "$STATE" = "success" ]; then
DESCRIPTION=$(jq -nr --arg tag "$IMAGE_TAG" --arg env "$TARGET_ENV" \
'"Deployed image \($tag) to \($env)"')
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

View File

@@ -7,14 +7,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& docker-php-ext-install mysqli bcmath intl gd \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& a2enmod rewrite
&& a2enmod rewrite \
&& sed -i 's/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf
RUN echo "date.timezone = \"\${PHP_TIMEZONE}\"" > /usr/local/etc/php/conf.d/timezone.ini
WORKDIR /app
COPY --chown=www-data:www-data . /app
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 \
RUN chmod 770 /app/writable/uploads /app/writable/logs /app/writable/cache \
&& mkdir -p /app/public/uploads/item_pics \
&& chown www-data:www-data /app/public/uploads/item_pics \
&& chmod 640 /app/.env \
&& ln -s /app/*[^public] /var/www \
&& rm -rf /var/www/html \
&& ln -nsf /app/public /var/www/html

View File

@@ -5,9 +5,8 @@
- [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 update -->
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
# Security Policy
@@ -22,116 +21,26 @@ We release patches for security vulnerabilities.
## 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).
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).
## Reporting a Vulnerability
**Option 1: GitHub Security Advisory (Preferred)**
Please report (suspected) security vulnerabilities to **[jeroen@steganos.dev](mailto:jeroen@steganos.dev)**.
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).
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.

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.
*
* Or via environment variable (useful for Docker/Compose):
* ALLOWED_HOSTNAMES=example.com,www.example.com
*
* 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/':
* ['media.example.com', 'accounts.example.com']
*
* @var list<string>
@@ -286,11 +286,7 @@ class App extends BaseConfig
// Solution for CodeIgniter 4 limitation: arrays cannot be set from .env
// See: https://github.com/codeigniter4/CodeIgniter4/issues/7311
// 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');
}
$envAllowedHostnames = getenv('app.allowedHostnames');
if ($envAllowedHostnames !== false && trim($envAllowedHostnames) !== '') {
$this->allowedHostnames = array_values(array_filter(
array_map('trim', explode(',', $envAllowedHostnames)),
@@ -331,8 +327,9 @@ class App extends BaseConfig
$errorMessage =
'Security: allowedHostnames is not configured. ' .
'Host header injection protection is disabled. ' .
'Set app.allowedHostnames in your .env file or ALLOWED_HOSTNAMES environment variable. ' .
'Example: app.allowedHostnames = "example.com,www.example.com" ' .
'Either set app.allowedHostnames in your .env file ' .
'(e.g., app.allowedHostnames = "example.com,www.example.com") ' .
'or configure $allowedHostnames in app/Config/App.php. ' .
'Received Host: ' . $httpHost;
// Production: Fail explicitly to prevent silent security vulnerabilities

View File

@@ -48,8 +48,7 @@ class OSPOS extends BaseConfig
$this->settings = [
'language' => 'english',
'language_code' => 'en',
'company' => 'Home',
'barcode_type' => 'Code39'
'company' => 'Home'
];
}
}

View File

@@ -246,7 +246,7 @@ class Attributes extends Secure_Controller
$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 | Attribute::SHOW_IN_SEARCH;
$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);

View File

@@ -924,9 +924,7 @@ class Config extends Secure_Controller
public function postSaveReceipt(): ResponseInterface
{
$batch_save_data = [
'receipt_template' => Sale_lib::isValidReceiptTemplate($this->request->getPost('receipt_template'))
? $this->request->getPost('receipt_template')
: 'receipt_default',
'receipt_template' => $this->request->getPost('receipt_template'),
'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

@@ -105,14 +105,13 @@ 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);
$definition_names = $this->attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS);
$sort = $this->sanitizeSortColumn(item_sort_columns(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'items.item_id');
$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'));
$definition_names = $this->attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS);
$filters = [
'start_date' => $this->request->getGet('start_date'),
'end_date' => $this->request->getGet('end_date'),
@@ -130,13 +129,6 @@ class Items extends Secure_Controller
// 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);
// When search_custom is enabled, include attributes that are searchable but may not be visible in table
if (!empty($filters['search_custom'])) {
$searchable_definitions = $this->attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_SEARCH);
$filters['definition_ids'] = array_keys($searchable_definitions);
}
$items = $this->item->search($search, $filters, $limit, $offset, $sort, $order);
$total_rows = $this->item->get_found_rows($search, $filters);
$data_rows = [];
@@ -162,23 +154,8 @@ class Items extends Secure_Controller
{
helper('file');
// 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');
}
$pic_filename = rawurldecode($pic_filename);
$file_extension = pathinfo($pic_filename, PATHINFO_EXTENSION);
$images = glob("./uploads/item_pics/$pic_filename");
$base_path = './uploads/item_pics/' . pathinfo($pic_filename, PATHINFO_FILENAME);

View File

@@ -1246,15 +1246,13 @@ 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'),
'bank_transfer' => lang('Sales.bank_transfer'),
'wallet' => lang('Sales.wallet'),
'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'),
'invoices' => lang('Sales.invoice')
];
}

View File

@@ -93,8 +93,6 @@ 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')
];
@@ -158,8 +156,6 @@ 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)
];
@@ -908,14 +904,6 @@ 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);
}
@@ -1171,13 +1159,6 @@ 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

@@ -272,9 +272,6 @@ 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

@@ -402,25 +402,6 @@ function item_headers(): array
];
}
/**
* Get all sortable column keys for items table, including dynamic attribute columns.
*
* @return array Array of column headers in format expected by sanitizeSortColumn
*/
function item_sort_columns(): array
{
$attribute = model(Attribute::class);
$definitionIds = array_keys($attribute->get_definitions_by_flags($attribute::SHOW_IN_ITEMS));
$headers = item_headers();
foreach ($definitionIds as $definitionId) {
$headers[] = [(string) $definitionId => ''];
}
return $headers;
}
/**
* Get the header for the items tabular view
*/
@@ -441,7 +422,7 @@ 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' => true];
$headers[] = [$definition_id => $definitionInfo['name'], 'sortable' => false];
}
$headers[] = ['inventory' => '', 'escape' => false];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "استلام البضائع",
"show_in_sales" => "اظهار خلال البيع",
"show_in_sales_visibility" => "البيع",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "تحديث الميزات",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "استلام البضائع",
"show_in_sales" => "اظهار خلال البيع",
"show_in_sales_visibility" => "البيع",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "تحديث الميزات",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Alınanlar",
"show_in_sales" => "Satışda göstərin",
"show_in_sales_visibility" => "Satışlar",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Atributları yenilə",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Ulazi",
"show_in_sales" => "Prikaži u prodaji",
"show_in_sales_visibility" => "Prodaja",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Ažuriraj atribut",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "بەدەستگەیشتووکان",
"show_in_sales" => "لە فرۆشتندا نیشانی بدە",
"show_in_sales_visibility" => "فرۆشتن",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "تایبەتمەندی نوێ بکەرەوە",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Modtagelser",
"show_in_sales" => "Vis i salg",
"show_in_sales_visibility" => "Salg",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Opdater egenskab",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,7 +30,5 @@ return [
"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",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,7 +30,5 @@ return [
"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",
];

View File

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

View File

@@ -30,7 +30,5 @@ return [
"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",
];

View File

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

View File

@@ -30,7 +30,5 @@ return [
"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",
];

View File

@@ -9,7 +9,6 @@ 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",
@@ -223,7 +222,6 @@ 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

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Recepciones",
"show_in_sales" => "Mostrar en Ventas",
"show_in_sales_visibility" => "Ventas",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Actualizar atributo",
];

View File

@@ -9,7 +9,6 @@ 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" => "",
@@ -223,7 +222,6 @@ 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

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "دریافت",
"show_in_sales" => "نمایش در فروش",
"show_in_sales_visibility" => "حراجی",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "به روز کردن ویژگی",
];

View File

@@ -30,7 +30,5 @@ return [
"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",
];

View File

@@ -9,7 +9,6 @@ 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" => "",
@@ -223,7 +222,6 @@ return [
"update" => "Éditer",
"upi" => "UPI",
"visa" => "",
"wallet" => "Portefeuille",
"wholesale" => "",
"work_order" => "Commande de travail",
"work_order_number" => "Numéro de commande",

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "קבלת סחורה",
"show_in_sales" => "הצג במכירות",
"show_in_sales_visibility" => "מכירות",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "עדכן מאפיין",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,7 +30,5 @@ return [
"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" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Tulajdonság frissítése",
];

View File

@@ -30,7 +30,5 @@ return [
"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",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Penerimaan",
"show_in_sales" => "Tampilkan dalam penjualan",
"show_in_sales_visibility" => "Penjualan",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Perbarui Atribut",
];

View File

@@ -30,7 +30,5 @@ return [
"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",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,7 +30,5 @@ return [
"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",
];

View File

@@ -30,7 +30,5 @@ return [
"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",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Orders",
"show_in_sales" => "Toon in verkoop",
"show_in_sales_visibility" => "Verkoop",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Wijzig Attribuut",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Leveringen",
"show_in_sales" => "Weergeven in verkopen",
"show_in_sales_visibility" => "Verkopen",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Kenmerk bijwerken",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Dostawy",
"show_in_sales" => "Pokaż w sprzedażach",
"show_in_sales_visibility" => "Sprzedaże",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Zaktualizuj atrybut",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Recebimentos",
"show_in_sales" => "Mostrar em vendas",
"show_in_sales_visibility" => "Vendas",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Atualizar atributo",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Receptii",
"show_in_sales" => "Arata in vanzari",
"show_in_sales_visibility" => "Vanzari",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Actualizare Atribut",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Закупки",
"show_in_sales" => "Показать в продажах",
"show_in_sales_visibility" => "Продажи",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Обновить атрибут",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Inleveranser",
"show_in_sales" => "Visa i försäljning",
"show_in_sales_visibility" => "Försäljning",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Uppdatera attribut",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Manunuzi",
"show_in_sales" => "Onyesha kwenye Mauzo",
"show_in_sales_visibility" => "Mauzo",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Sasisha Sifa",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Manunuzi",
"show_in_sales" => "Onyesha kwenye Mauzo",
"show_in_sales_visibility" => "Mauzo",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Sasisha Sifa",
];

View File

@@ -30,7 +30,5 @@ return [
"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",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "สินค้าขาเข้า",
"show_in_sales" => "แสดงใน การขาย",
"show_in_sales_visibility" => "การขาย",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "ปรับปรุงแอตทริบิวต์",
];

View File

@@ -30,7 +30,5 @@ return [
"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",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Alacaklar",
"show_in_sales" => "Satışlarda göster",
"show_in_sales_visibility" => "Satışlar",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Nitelik Güncelle",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Надходження",
"show_in_sales" => "Показати в продажах",
"show_in_sales_visibility" => "Продажі",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Оновити атрибут",
];

View File

@@ -30,7 +30,5 @@ return [
"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",
];

View File

@@ -30,7 +30,5 @@ return [
"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" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Cập nhật thuộc tính",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "收据",
"show_in_sales" => "在销售中显示",
"show_in_sales_visibility" => "销售",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "更新属性",
];

View File

@@ -30,7 +30,5 @@ return [
"show_in_receivings_visibility" => "收貨",
"show_in_sales" => "在銷售中顯示",
"show_in_sales_visibility" => "銷售",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "更新屬性",
];

View File

@@ -108,11 +108,6 @@ class Sale_lib
'custom_tax_invoice'
];
private const ALLOWED_RECEIPT_TEMPLATES = [
'receipt_default',
'receipt_short'
];
public function get_invoice_type_options(): array
{
$invoice_types = [];
@@ -166,11 +161,6 @@ 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

@@ -38,10 +38,9 @@ class Attribute extends Model
'attribute_decimal'
];
public const SHOW_IN_ITEMS = 1;
public const SHOW_IN_ITEMS = 1; // TODO: These need to be moved to constants.php
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);

View File

@@ -31,7 +31,6 @@ class Item extends Model
'allow_alt_description',
'is_serialized'
];
protected $table = 'items';
protected $primaryKey = 'item_id';
protected $useAutoIncrement = true;
@@ -59,6 +58,7 @@ class Item extends Model
'hsn_code'
];
/**
* Determines if a given item_id is an item
*/
@@ -132,186 +132,32 @@ class Item extends Model
return $this->search($search, $filters, 0, 0, 'items.name', 'asc', true);
}
/**
* 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 parseAttributeSearch(string $search): array
{
$result = [
'terms' => [],
'attributes' => []
];
if ($search === '') {
return $result;
}
$pattern = '/([[:alpha:]][[:alnum:] _-]*?)\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;
}
/**
* Search for items by attribute values
* Returns an array of item_ids matching the attribute search criteria
*
* @param string $search Search term
* @param array $definitionIds Attribute definition IDs to search within
* @param bool $matchDeleted Whether to match items where deleted flag equals this value
* @param string $logic 'AND' or 'OR' for multiple attribute matching
* @return array Array of matching item_ids
*/
public function searchByAttributes(string $search, array $definitionIds, bool $matchDeleted = false, string $logic = 'OR'): array
{
if ($definitionIds === [] || $search === '') {
return [];
}
$parsed = $this->parseAttributeSearch($search);
$matchingItemIds = [];
if (!empty($parsed['attributes'])) {
$attribute = model(Attribute::class);
$allDefinitions = $attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_SEARCH, true);
$definitionNameToId = [];
foreach ($allDefinitions as $id => $defInfo) {
$name = is_array($defInfo) ? $defInfo['name'] : $defInfo;
$definitionNameToId[strtolower($name)] = (int) $id;
}
foreach ($parsed['attributes'] as $attrName => $values) {
if (!isset($definitionNameToId[$attrName])) {
continue;
}
$definitionId = $definitionNameToId[$attrName];
// Skip if this attribute is not in the caller-provided definitionIds filter
if (!in_array($definitionId, $definitionIds, true)) {
continue;
}
foreach ($values as $value) {
$builder = $this->db->table('attribute_links');
$builder->select('DISTINCT attribute_links.item_id');
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id');
$builder->join('items', 'items.item_id = attribute_links.item_id');
$builder->groupStart();
$builder->like('attribute_values.attribute_value', $value);
$builder->orWhere('attribute_values.attribute_decimal', $value);
$builder->orWhere('attribute_values.attribute_date', $value);
$builder->groupEnd();
$builder->where('attribute_links.definition_id', $definitionId);
$builder->where('attribute_links.sale_id', null);
$builder->where('attribute_links.receiving_id', null);
$builder->where('items.deleted', $matchDeleted);
$foundIds = array_column($builder->get()->getResultArray(), 'item_id');
if ($logic === 'AND') {
if (empty($matchingItemIds)) {
$matchingItemIds = $foundIds;
} else {
$matchingItemIds = array_intersect($matchingItemIds, $foundIds);
}
} else {
$matchingItemIds = array_unique(array_merge($matchingItemIds, $foundIds));
}
}
}
}
if (!empty($parsed['terms'])) {
$term = implode(' ', $parsed['terms']);
$termIds = $this->searchByAttributeValue($term, $definitionIds, $matchDeleted);
if (empty($matchingItemIds)) {
return $termIds;
}
return $logic === 'AND'
? array_values(array_intersect($matchingItemIds, $termIds))
: array_values(array_unique(array_merge($matchingItemIds, $termIds)));
}
return $matchingItemIds;
}
/**
* Search for items by a single attribute value
*
* @param string $search Search term
* @param array $definitionIds Attribute definition IDs to search within
* @param bool $matchDeleted Whether to match items where deleted flag equals this value
* @return array Array of matching item_ids
*/
private function searchByAttributeValue(string $search, array $definitionIds, bool $matchDeleted = false): array
{
$builder = $this->db->table('attribute_links');
$builder->select('DISTINCT attribute_links.item_id');
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id');
$builder->join('items', 'items.item_id = attribute_links.item_id');
$builder->groupStart();
$builder->like('attribute_values.attribute_value', $search);
$builder->orWhere('attribute_values.attribute_decimal', $search);
$builder->orWhere('attribute_values.attribute_date', $search);
$builder->groupEnd();
$builder->whereIn('attribute_links.definition_id', $definitionIds);
$builder->where('attribute_links.sale_id', null);
$builder->where('attribute_links.receiving_id', null);
$builder->where('items.deleted', $matchDeleted);
return array_column($builder->get()->getResultArray(), 'item_id');
}
/**
* 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;
}
/**
* Perform a search on items
*/
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)
{
$rows = $rows ?? 0;
$limit_from = $limit_from ?? 0;
$sort = $sort ?? 'items.name';
$order = $order ?? 'asc';
$count_only = $count_only ?? false;
// Set default values
if ($rows == null) {
$rows = 0;
}
if ($limit_from == null) {
$limit_from = 0;
}
if ($sort == null) {
$sort = 'items.name';
}
if ($order == null) {
$order = 'asc';
}
if ($count_only == null) {
$count_only = false;
}
$config = config(OSPOS::class)->settings;
$builder = $this->db->table('items AS items');
$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
// get_found_rows case
if ($count_only) {
$builder->select('COUNT(DISTINCT items.item_id) AS count');
} else {
@@ -366,33 +212,13 @@ class Item extends Model
: 'trans_date BETWEEN ' . $this->db->escape(rawurldecode($filters['start_date'])) . ' AND ' . $this->db->escape(rawurldecode($filters['end_date']));
$builder->where($where);
$attributesEnabled = count($filters['definition_ids']) > 0;
$matchingItemIds = [];
$attributes_enabled = count($filters['definition_ids']) > 0;
if ($search !== '' && $attributesEnabled && $filters['search_custom']) {
$matchingItemIds = $this->searchByAttributes($search, $filters['definition_ids'], $filters['is_deleted']);
}
if ($search !== '') {
if ($attributesEnabled && $filters['search_custom']) {
if (empty($matchingItemIds)) {
$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();
} else {
$builder->groupStart();
$builder->whereIn('items.item_id', $matchingItemIds);
$builder->orLike('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 (!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);
@@ -404,43 +230,16 @@ class Item extends Model
}
}
if ($attributesEnabled && !$count_only) {
if ($attributes_enabled) {
$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');
$sanitizedIds = array_map('intval', $filters['definition_ids']);
$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(',', $sanitizedIds) . ')', 'left');
$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');
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id', 'left');
}
// Handle attribute column sorting
$sortDefinitionId = $this->getAttributeSortDefinitionId($sort);
if ($sortDefinitionId !== null && $attributesEnabled && !$count_only) {
$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');
// Determine the correct column to sort by based on attribute type
$attribute = model(Attribute::class);
$definitionInfo = $attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS, true);
$sortColumn = "{$sortAlias}_val.attribute_value"; // default to text
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($sortColumn, $order);
} else {
$builder->orderBy($sort, $order);
}
$builder->where('items.deleted', $filters['is_deleted']);
if ($filters['empty_upc']) {
@@ -462,12 +261,17 @@ class Item extends Model
$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);
}

View File

@@ -294,9 +294,7 @@ 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.bank_transfer') => lang('Sales.bank_transfer'),
lang('Sales.wallet') => lang('Sales.wallet')
lang('Sales.due') => lang('Sales.due')
];
}

View File

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

View File

@@ -277,14 +277,6 @@ 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();
@@ -1517,13 +1509,5 @@ 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,14 +2,11 @@
/**
* @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') ?>
@@ -64,6 +61,6 @@ if (isset($error_message)) {
<?php endif; ?>
</div>
<?= view('sales/' . $template) ?>
<?= view('sales/' . $config['receipt_template']) ?>
<?= view('partial/footer') ?>

View File

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

View File

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

View File

@@ -18,7 +18,6 @@ class AppTest extends CIUnitTestCase
// Clean up environment
putenv('CI_ENVIRONMENT');
putenv('app.allowedHostnames');
putenv('ALLOWED_HOSTNAMES');
unset($_SERVER['HTTP_HOST']);
}
@@ -282,106 +281,4 @@ 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');
}
}