Compare commits

..

11 Commits

Author SHA1 Message Date
Ollama
e3c2175b04 feat: Add show_in_search language strings to all languages
- Add 'show_in_search' and 'show_in_search_visibility' to all language files
- Translated: de-DE, es-ES, fr, it
- Placeholder English for remaining languages (awaiting translation)

This ensures the SHOW_IN_SEARCH attribute flag works in all locales.
2026-05-16 21:02:34 +02:00
Ollama
2d93a96ac9 fix: Make item_sort_columns return format compatible with sanitizeSortColumn
- sanitizeSortColumn expects nested array format [['col' => 'label'], ...]
- Reuse item_headers() as base and add attribute columns
- This maintains DRY principle by not redefining columns twice
2026-05-16 21:02:34 +02:00
Ollama
c681dc51ed fix: Use typed column for attribute sorting (DECIMAL/DATE/TEXT)
- When sorting by attribute column, determine the attribute type
- Use attribute_decimal for DECIMAL type
- Use attribute_date for DATE type
- Use attribute_value for TEXT/DROPDOWN/CHECKBOX types
- This ensures numeric and date attributes sort correctly instead of lexicographically

Fixes CodeRabbit feedback on PR #4442.
2026-05-16 21:02:34 +02:00
Ollama
65568cf224 fix: Address remaining PR review comments
- Update regex pattern to support multi-word attribute names (e.g., 'aspect ratio')
- Rename $includeDeleted to $matchDeleted for clarity (matches deleted flag value)
- Add check to skip attributes not in caller-provided definitionIds filter
- Use Unicode pattern modifier 'u' for international character support in attribute names

This addresses CodeRabbit's feedback on PR #4442.
2026-05-16 21:02:34 +02:00
Ollama
5cb4371344 fix: Address PR review comments
- Rename methods to camelCase (PSR-12): parseAttributeSearch, searchByAttributes, searchByAttributeValue, getAttributeSortDefinitionId
- Rename variables to camelCase (PSR-12)
- Add DATE attribute search support in searchByAttributeValue
- Fix get_definitions_by_flags to request typed payload (second param true)
- Handle both array and string return shapes for definition info
- Fix term-only search to merge with attribute matches instead of replacing
- Use strict emptiness checks (=== '' instead of empty())
- Sanitize definition_ids before SQL interpolation (array_map intval)
- Include DATE in attribute search conditions
2026-05-16 21:02:34 +02:00
Ollama
281f402b8e style: Apply PSR-12 formatting
- Fix PHPDoc comment format in item_sort_columns() helper
- Remove duplicate blank line after properties in Item model
- Remove unused variable assignment in Items controller
2026-05-16 21:02:34 +02:00
Ollama
c74fa7aeb5 Refactor: Use existing sanitizeSortColumn method with item_sort_columns helper
- Add item_sort_columns() helper function in tabular_helper.php
- Helper returns all sortable columns including dynamic attribute IDs
- Remove duplicate sanitizeSortColumnAttribute method from Items controller
- Remove unused ALLOWED_SORT_COLUMNS constant from Item model
- Reuses existing sanitizeSortColumn method from Secure_Controller
2026-05-16 21:02:34 +02:00
Ollama
be01555a34 Refactor: Add ALLOWED_SORT_COLUMNS constant and reuse in sanitization
- Add Item::ALLOWED_SORT_COLUMNS constant for allowed sort columns
- Use constant in sanitizeSortColumnAttribute() instead of inline array
- Enables reuse across the codebase for sort column validation
2026-05-16 21:02:34 +02:00
Ollama
abc381d35c Implement Phase 3: Multi-attribute search AND logic and Phase 4: Sort by attribute columns
Phase 3 - Multi-attribute Search:
- Add parse_attribute_search() method to parse search syntax like
  'color: blue size: large' or 'color:blue AND size:large'
- Update search_by_attributes() to support AND/OR logic for multiple
  attribute queries
- Add search_by_attribute_value() private method for single value search

Phase 4 - Sort by Attribute Columns:
- Add get_attribute_sort_definition_id() method to detect attribute
  column sorting
- Update Item::search() to join attribute tables when sorting by
  attribute columns
- Update tabular_helper to make attribute columns sortable
- Add sanitizeSortColumnAttribute() method in Items controller to
  validate attribute definition IDs as sort columns

Fixes #2722 - sorting by attribute columns now works
2026-05-16 21:02:34 +02:00
Ollama
cacd320206 Add SHOW_IN_SEARCH flag to separate attribute search from visibility
Phase 2 implementation:
- Add SHOW_IN_SEARCH constant (value 8) to Attribute model
- Update Items controller to include searchable attributes when
  search_custom filter is enabled
- Add language strings for the new visibility flag
- Update Attributes controller to include new flag in form

This allows attributes to be searchable even if they are not
displayed in the items table, addressing issue #2919.

Fixes #2919
2026-05-16 21:02:34 +02:00
Ollama
a7a52f800c Refactor attribute search to fix pagination and multi-attribute search
- Add new search_by_attributes() method that returns matching item_ids
- Refactor search() method to use subquery approach instead of HAVING LIKE
- Fix pagination count issue (#2819) - get_found_rows() now returns correct count
- Enable combined search (attributes OR regular fields)
- GROUP_CONCAT still used for display but removed from search/count logic
- Fixes #2407 - multi-attribute search now works correctly

Related: #2819, #2407, #2722, #2919
2026-05-16 21:02:34 +02:00
58 changed files with 384 additions and 1083 deletions

View File

@@ -1,94 +0,0 @@
#!/bin/bash
set -euo pipefail
# Shared version tag generation script for GitHub Actions workflows
# Usage: ./get-version.sh [FORMAT] [SHA_LENGTH]
#
# Formats:
# docker-tag - Docker image tag (default)
# archive - Archive filename suffix
# all - Output all version variables for GITHUB_OUTPUT
#
# Environment variables:
# GITHUB_REF - Git ref (e.g., refs/heads/master, refs/pull/123/merge)
# GITHUB_SHA - Git commit SHA
# GITHUB_EVENT_NAME - Event that triggered workflow (push, pull_request, etc.)
# GITHUB_EVENT_PATH - Path to event JSON (for PR number extraction)
# GITHUB_OUTPUT - Path to GITHUB_OUTPUT file (when format=all)
# Ensure we're in a git repository with source files
cd "${GITHUB_WORKSPACE:-.}"
# Get version from App.php
VERSION=$(grep "application_version" app/Config/App.php | sed "s/.*= '\(.*\)';/\1/g")
# Standardize SHA length (default: 7 chars)
SHA_LENGTH="${2:-7}"
SHA="${GITHUB_SHA:0:$SHA_LENGTH}"
# Initialize variables
IMAGE_TAG=""
BRANCH=""
# Detect event type and generate appropriate tag
if [[ "$GITHUB_EVENT_NAME" == "pull_request" || "$GITHUB_EVENT_NAME" == "pull_request_review" ]]; then
# Extract PR number from event JSON
if [[ -f "${GITHUB_EVENT_PATH:-}" ]]; then
PR_NUMBER=$(jq -r '.pull_request.number // .number // empty' < "$GITHUB_EVENT_PATH" 2>/dev/null || true)
if [[ -n "$PR_NUMBER" ]]; then
# PR-based tag (for PR deployments)
IMAGE_TAG="pr-${PR_NUMBER}-${SHA}"
BRANCH="pr-${PR_NUMBER}"
fi
fi
# Fallback if we couldn't extract PR number
if [[ -z "$IMAGE_TAG" ]]; then
# Try to extract from GITHUB_REF
PR_NUMBER=$(echo "$GITHUB_REF" | grep -oP 'pull/\K[0-9]+' || true)
if [[ -n "$PR_NUMBER" ]]; then
IMAGE_TAG="pr-${PR_NUMBER}-${SHA}"
BRANCH="pr-${PR_NUMBER}"
else
# Last resort: use SHA only
IMAGE_TAG="${SHA}"
BRANCH="unknown"
fi
fi
else
# Branch-based tag (for push events)
BRANCH="${GITHUB_REF#refs/heads/}"
BRANCH=$(echo "$BRANCH" | sed 's/feature\///' | tr '/' '_')
if [[ "$BRANCH" == "master" ]]; then
# Master builds: use version as tag
IMAGE_TAG="${VERSION}"
else
# Feature branch builds: version + branch + sha
IMAGE_TAG="${VERSION}-${BRANCH}-${SHA}"
fi
fi
# Output format based on first argument
case "${1:-docker-tag}" in
docker-tag)
echo "$IMAGE_TAG"
;;
archive)
echo "${VERSION}.${SHA}"
;;
all)
{
echo "version=${VERSION}"
echo "version-tag=${IMAGE_TAG}"
echo "short-sha=${SHA}"
echo "branch=${BRANCH}"
} >> "$GITHUB_OUTPUT"
echo "::debug::version=${VERSION}, version-tag=${IMAGE_TAG}, short-sha=${SHA}, branch=${BRANCH}"
;;
*)
echo "::error::Unknown format: $1" >&2
exit 1
;;
esac

View File

@@ -22,7 +22,6 @@ jobs:
version: ${{ steps.version.outputs.version }}
version-tag: ${{ steps.version.outputs.version-tag }}
short-sha: ${{ steps.version.outputs.short-sha }}
branch: ${{ steps.version.outputs.branch }}
steps:
- name: Checkout
@@ -76,13 +75,16 @@ jobs:
- name: Get version info
id: version
run: |
chmod +x .github/scripts/get-version.sh
.github/scripts/get-version.sh all 7
VERSION=$(grep "application_version" app/Config/App.php | sed "s/.*= '\(.*\)';/\1/g")
BRANCH=$(echo "${GITHUB_REF#refs/heads/}" | sed 's/feature\///' | tr '/' '_')
TAG=$(echo "${GITHUB_TAG:-$BRANCH}" | tr '/' '_')
SHORT_SHA=$(git rev-parse --short=6 HEAD)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version-tag=$VERSION-$BRANCH-$SHORT_SHA" >> $GITHUB_OUTPUT
echo "short-sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
env:
GITHUB_EVENT_PATH: ${{ github.event_path }}
GITHUB_SHA: ${{ github.sha }}
GITHUB_REF: ${{ github.ref }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_TAG: ${{ github.ref_name }}
- name: Create .env file
run: |
@@ -128,7 +130,7 @@ jobs:
name: Build Docker Image
runs-on: ubuntu-22.04
needs: build
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
if: github.event_name == 'push'
steps:
- name: Download build context
@@ -152,14 +154,14 @@ jobs:
- name: Determine Docker tags
id: tags
run: |
TAG="${{ needs.build.outputs.version-tag }}"
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${TAG}" >> $GITHUB_OUTPUT
elif [ "${{ needs.build.outputs.branch }}" = "master" ]; then
echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${TAG},${{ secrets.DOCKER_USERNAME }}/opensourcepos:latest" >> $GITHUB_OUTPUT
BRANCH=$(echo "${GITHUB_REF#refs/heads/}" | tr '/' '_')
if [ "$BRANCH" = "master" ]; then
echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${{ needs.build.outputs.version-tag }},${{ secrets.DOCKER_USERNAME }}/opensourcepos:master" >> $GITHUB_OUTPUT
else
echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${TAG}" >> $GITHUB_OUTPUT
echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${{ needs.build.outputs.version-tag }}" >> $GITHUB_OUTPUT
fi
env:
GITHUB_REF: ${{ github.ref }}
- name: Build and push Docker images
uses: docker/build-push-action@v5
@@ -192,7 +194,7 @@ jobs:
id: version
run: |
VERSION="${{ needs.build.outputs.version }}"
SHORT_SHA="${{ needs.build.outputs.short-sha }}"
SHORT_SHA=$(git rev-parse --short=6 HEAD)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "short-sha=$SHORT_SHA" >> $GITHUB_OUTPUT

View File

@@ -1,219 +0,0 @@
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

View File

@@ -1,82 +0,0 @@
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
run: |
chmod +x .github/scripts/get-version.sh
IMAGE_TAG=$(.github/scripts/get-version.sh docker-tag 7)
echo "tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
env:
GITHUB_EVENT_PATH: ${{ github.event_path }}
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
GITHUB_REF: ${{ github.ref }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
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"

View File

@@ -1,23 +0,0 @@
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

@@ -1,131 +0,0 @@
name: Install Script Test
on:
push:
paths:
- 'scripts/install-ubuntu.sh'
- '.github/workflows/install-script-test.yml'
pull_request:
paths:
- 'scripts/install-ubuntu.sh'
- '.github/workflows/install-script-test.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
install-test:
name: Test Install Script (${{ matrix.scenario }})
runs-on: ubuntu-22.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- scenario: default
db_pass: ''
- scenario: custom-password
db_pass: 'TestPass123!'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Make install script executable
run: chmod +x scripts/install-ubuntu.sh
- name: Run install script
env:
DB_PASS: ${{ matrix.db_pass }}
run: |
set -o pipefail
echo "Running install script with scenario: ${{ matrix.scenario }}"
sudo -E bash scripts/install-ubuntu.sh 2>&1 | tee install-output.log
echo "Install completed successfully"
- name: Wait for services to stabilize
run: sleep 10
- name: Verify Apache is running
run: |
echo "Checking Apache status..."
sudo systemctl status apache2 --no-pager
sudo systemctl is-active apache2
- name: Verify MariaDB is running
run: |
echo "Checking MariaDB status..."
sudo systemctl status mariadb --no-pager
sudo systemctl is-active mariadb
- name: Verify Apache HTTP response
run: |
echo "Testing HTTP response on port 80..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/)
echo "HTTP Response Code: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ]; then
echo "Apache is responding correctly"
elif [ "$HTTP_CODE" = "500" ]; then
echo "HTTP 500 - Application error. Checking .env configuration..."
sudo cat /var/www/ospos/.env 2>/dev/null | grep -E "database\.default\.(hostname|database|username|password)|encryption\.key|CI_ENVIRONMENT" | head -10
sudo cat /var/www/ospos/writable/logs/*.log 2>/dev/null | tail -20 || true
curl -s http://localhost/ | head -50
exit 1
else
echo "Unexpected HTTP code: $HTTP_CODE"
exit 1
fi
- name: Verify OSPOS login page
run: |
echo "Checking OSPOS login page..."
curl -s http://localhost/ | grep -qi "login\|password\|username" && echo "Login page content found" || {
echo "Login page verification failed"
curl -s http://localhost/ | head -50
exit 1
}
- name: Verify database exists
env:
DB_PASS: ${{ matrix.db_pass != '' && matrix.db_pass || '' }}
run: |
echo "Verifying database..."
# Extract the generated password from install output if using default
if [ -z "${{ matrix.db_pass }}" ]; then
GENERATED_PASS=$(grep -oP 'Database Password: \K[^\s]+' install-output.log || true)
if [ -n "$GENERATED_PASS" ]; then
DB_PASS="$GENERATED_PASS"
fi
fi
# Check database exists
sudo mysql -u root -e "SHOW DATABASES LIKE 'ospos';" | grep -q ospos && echo "Database 'ospos' exists" || {
echo "Database 'ospos' not found"
sudo mysql -u root -e "SHOW DATABASES;"
exit 1
}
# Check tables exist
TABLE_COUNT=$(sudo mysql -u root ospos -e "SHOW TABLES;" | wc -l)
echo "Found $TABLE_COUNT tables in database"
if [ "$TABLE_COUNT" -gt 5 ]; then
echo "Database tables verified"
else
echo "Not enough tables found"
exit 1
fi
- name: Upload install log
uses: actions/upload-artifact@v4
if: always()
with:
name: install-log-${{ matrix.scenario }}
path: install-output.log
retention-days: 7

View File

@@ -102,73 +102,5 @@ Do **not** use below command on live deployments unless you want to tear everyth
## Cloud install
### Recommended: DigitalOcean
Sign up through [our referral link](https://m.do.co/c/ac38c262507b) to get a [**$100, 60-day credit**](https://m.do.co/c/ac38c262507b).
1. Create an Ubuntu 20.04+ or 22.04+ droplet
2. SSH into your server: `ssh root@<your-droplet-ip>`
3. Run the one-line installer:
```bash
curl -sSL https://opensourcepos.org/install | sudo bash
```
The installer will:
- Install Apache, MariaDB, PHP 8.2 and required extensions
- Download the **latest stable release** of OSPOS from GitHub
- Create a database with secure random password
- Configure OSPOS and Apache
- **Set up SSL/TLS certificates** (interactive prompt or environment variables)
- Display login credentials after completion
**Interactive Mode (Recommended for first-time users):**
When run without environment variables, the installer will prompt you:
1. Whether to configure SSL (recommended for production)
2. Your domain name (e.g., `pos.example.com`)
3. Your email for Let's Encrypt (for production SSL)
```bash
curl -sSL https://opensourcepos.org/install | sudo bash
# Script will ask:
# - Configure SSL? (y/n)
# - Domain name: pos.example.com
# - Email for Let's Encrypt: admin@example.com
```
**Non-Interactive Mode (for automation):**
```bash
# Development (no SSL)
curl -sSL https://opensourcepos.org/install | APACHE_SERVER_NAME=localhost sudo -E bash
# Production with Let's Encrypt SSL
curl -sSL https://opensourcepos.org/install | APACHE_SERVER_NAME=pos.example.com SSL_EMAIL=admin@example.com sudo -E bash
# Custom database password
curl -sSL https://opensourcepos.org/install | DB_PASS=securepassword APACHE_SERVER_NAME=pos.example.com SSL_EMAIL=admin@example.com sudo -E bash
```
**Environment variables:**
- `DB_HOST` - Database host (default: localhost)
- `DB_NAME` - Database name (default: ospos)
- `DB_USER` - Database user (default: ospos)
- `DB_PASS` - Database password (default: auto-generated)
- `MYSQL_ROOT_PASS` - MariaDB root password (default: empty/no password)
- `OSPOS_DIR` - Installation directory (default: /var/www/ospos)
- `OSPOS_VERSION` - OSPOS version to install (default: latest stable release)
- `PHP_VERSION` - PHP version (default: 8.2)
- `APACHE_SERVER_NAME` - Server hostname (default: localhost, or set interactively)
- `SSL_EMAIL` - Email for Let's Encrypt. When set, enables production SSL with auto-renewal
- `SSL_DOMAIN` - Alternative to `APACHE_SERVER_NAME` for SSL certificate domain
> **Testing:** This installer is tested with each commit via our CI workflow. A fresh Ubuntu container is spawned, the script runs to completion, and basic sanity checks verify the installation. For production deployments, we recommend testing on a staging server first. If you encounter issues, please [open an issue](https://github.com/opensourcepos/opensourcepos/issues/new?template=bug_report.yml) with your server version and error output.
> **Note:** If the short URL is unavailable, use the direct GitHub URL:
> ```bash
> curl -sSL https://raw.githubusercontent.com/opensourcepos/opensourcepos/master/scripts/install-ubuntu.sh | sudo bash
> ```
For other cloud providers or manual installation, see the [detailed installation guide](https://github.com/opensourcepos/opensourcepos/wiki/Getting-Started-installations) in the wiki.
**Important:** Change the default password after first login!
If you choose DigitalOcean:
[Through this link](https://m.do.co/c/ac38c262507b), you will get a [**free $100, 60-day credit**](https://m.do.co/c/ac38c262507b). [Check the wiki](https://github.com/opensourcepos/opensourcepos/wiki/Getting-Started-installations) for further instructions on how to install the necessary components.

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;
$show_all = Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_RECEIVINGS | Attribute::SHOW_IN_SALES | Attribute::SHOW_IN_SEARCH;
$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

@@ -105,13 +105,14 @@ class Items extends Secure_Controller
$search = $this->request->getGet('search', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
$offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
$sort = $this->sanitizeSortColumn(item_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'item_id');
$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');
$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'),
@@ -129,6 +130,13 @@ 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 = [];
@@ -1055,20 +1063,14 @@ class Items extends Secure_Controller
});
if (!$isFailedRow && $this->item->save_value($itemData, $itemId)) {
if (!$this->save_tax_data($row, $itemData)) {
$isFailedRow = true;
}
if (!$this->save_inventory_quantities($row, $itemData, $allowedStockLocations, $employeeId)) {
$isFailedRow = true;
}
$this->save_tax_data($row, $itemData);
$this->save_inventory_quantities($row, $itemData, $allowedStockLocations, $employeeId);
$csvAttributeValues = $this->extractAttributeData($row);
if (!$this->attribute->saveCSVRowAttributeData($csvAttributeValues, $itemData, $attributeData)) {
$isFailedRow = true;
}
$isFailedRow = !$this->attribute->saveCSVRowAttributeData($csvAttributeValues, $itemData, $attributeData);
if ($isFailedRow) {
$failedRow = $key + 2;
$failCodes[] = $failedRow;
log_message('error', "CSV Item import failed on line $failedRow while saving item.");
log_message('error', "CSV Item import failed on line $failedRow while saving attributes.");
continue;
}
@@ -1258,15 +1260,13 @@ 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): bool
private function save_inventory_quantities(array $row, array $item_data, array $allowed_locations, int $employee_id): void
{
// 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];
@@ -1280,22 +1280,20 @@ class Items extends Secure_Controller
if (!empty($row["location_$location_name"]) || $row["location_$location_name"] === '0') {
$item_quantity_data['quantity'] = $row["location_$location_name"];
$success &= $this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$csv_data['trans_inventory'] = $row["location_$location_name"];
$success &= (bool)$this->inventory->insert($csv_data, false);
$this->inventory->insert($csv_data, false);
} elseif ($is_update) {
continue;
return;
} else {
$item_quantity_data['quantity'] = 0;
$success &= $this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$csv_data['trans_inventory'] = 0;
$success &= (bool)$this->inventory->insert($csv_data, false);
$this->inventory->insert($csv_data, false);
}
}
return (bool)$success;
}
/**
@@ -1303,9 +1301,8 @@ 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): bool
private function save_tax_data(array $row, array $item_data): void
{
$items_taxes_data = [];
@@ -1317,11 +1314,9 @@ class Items extends Secure_Controller
$items_taxes_data[] = ['name' => $row['Tax 2 Name'], 'percent' => $row['Tax 2 Percent']];
}
if (!empty($items_taxes_data)) {
return $this->item_taxes->save_value($items_taxes_data, $item_data['item_id']);
if (isset($items_taxes_data)) {
$this->item_taxes->save_value($items_taxes_data, $item_data['item_id']);
}
return true;
}
/**

View File

@@ -402,6 +402,25 @@ 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
*/
@@ -422,7 +441,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' => false];
$headers[] = [$definition_id => $definitionInfo['name'], 'sortable' => true];
}
$headers[] = ['inventory' => '', 'escape' => false];

View File

@@ -30,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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

@@ -30,5 +30,7 @@ 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

@@ -30,5 +30,7 @@ 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,5 +30,7 @@ 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

@@ -30,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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,5 +30,7 @@ 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

@@ -38,9 +38,10 @@ class Attribute extends Model
'attribute_decimal'
];
public const SHOW_IN_ITEMS = 1; // TODO: These need to be moved to constants.php
public const SHOW_IN_ITEMS = 1;
public const SHOW_IN_SALES = 2;
public const SHOW_IN_RECEIVINGS = 4;
public const SHOW_IN_SEARCH = 8;
public function deleteDropdownAttributeValue(string $attribute_value, int $definition_id): bool
{
$attribute_id = $this->getAttributeIdByValue($attribute_value);
@@ -601,10 +602,6 @@ 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

@@ -31,6 +31,7 @@ class Item extends Model
'allow_alt_description',
'is_serialized'
];
protected $table = 'items';
protected $primaryKey = 'item_id';
protected $useAutoIncrement = true;
@@ -58,7 +59,6 @@ class Item extends Model
'hsn_code'
];
/**
* Determines if a given item_id is an item
*/
@@ -132,32 +132,186 @@ 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)
{
// 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;
}
$rows = $rows ?? 0;
$limit_from = $limit_from ?? 0;
$sort = $sort ?? 'items.name';
$order = $order ?? 'asc';
$count_only = $count_only ?? false;
$config = config(OSPOS::class)->settings;
$builder = $this->db->table('items AS items'); // TODO: I'm not sure if it's needed to write items AS items... I think you can just get away with items
$builder = $this->db->table('items AS items');
// get_found_rows case
if ($count_only) {
$builder->select('COUNT(DISTINCT items.item_id) AS count');
} else {
@@ -212,13 +366,33 @@ 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);
$attributes_enabled = count($filters['definition_ids']) > 0;
$attributesEnabled = count($filters['definition_ids']) > 0;
$matchingItemIds = [];
if (!empty($search)) {
if ($attributes_enabled && $filters['search_custom']) {
$builder->havingLike('attribute_values', $search);
$builder->orHavingLike('attribute_dtvalues', $search);
$builder->orHavingLike('attribute_dvalues', $search);
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();
}
} else {
$builder->groupStart();
$builder->like('name', $search);
@@ -230,16 +404,43 @@ class Item extends Model
}
}
if ($attributes_enabled) {
if ($attributesEnabled && !$count_only) {
$format = $this->db->escape(dateformat_mysql());
$this->db->simpleQuery('SET SESSION group_concat_max_len=49152');
$builder->select('GROUP_CONCAT(DISTINCT CONCAT_WS(\'_\', definition_id, attribute_value) ORDER BY definition_id SEPARATOR \'|\') AS attribute_values');
$builder->select("GROUP_CONCAT(DISTINCT CONCAT_WS('_', definition_id, DATE_FORMAT(attribute_date, $format)) SEPARATOR '|') AS attribute_dtvalues");
$builder->select('GROUP_CONCAT(DISTINCT CONCAT_WS(\'_\', definition_id, attribute_decimal) SEPARATOR \'|\') AS attribute_dvalues');
$builder->join('attribute_links', 'attribute_links.item_id = items.item_id AND attribute_links.receiving_id IS NULL AND attribute_links.sale_id IS NULL AND definition_id IN (' . implode(',', $filters['definition_ids']) . ')', 'left');
$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_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']) {
@@ -261,17 +462,12 @@ 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

@@ -1,381 +0,0 @@
#!/bin/bash
set -e
COLOR_RED='\033[0;31m'
COLOR_GREEN='\033[0;32m'
COLOR_YELLOW='\033[1;33m'
COLOR_BLUE='\033[0;34m'
COLOR_RESET='\033[0m'
echo -e "${COLOR_BLUE}╔══════════════════════════════════════════════════════════╗${COLOR_RESET}"
echo -e "${COLOR_BLUE}║ Open Source Point of Sale - Ubuntu Installer ║${COLOR_RESET}"
echo -e "${COLOR_BLUE}║ Version 3.4+ ║${COLOR_RESET}"
echo -e "${COLOR_BLUE}╚══════════════════════════════════════════════════════════╝${COLOR_RESET}"
echo ""
if [ "$EUID" -ne 0 ]; then
echo -e "${COLOR_RED}Please run this script as root or with sudo${COLOR_RESET}"
exit 1
fi
export DEBIAN_FRONTEND=noninteractive
DB_HOST="${DB_HOST:-localhost}"
DB_NAME="${DB_NAME:-ospos}"
DB_USER="${DB_USER:-ospos}"
DB_PASS="${DB_PASS:-$(openssl rand -base64 24)}"
OSPOS_DIR="${OSPOS_DIR:-/var/www/ospos}"
OSPOS_VERSION="${OSPOS_VERSION:-}"
PHP_VERSION="${PHP_VERSION:-8.2}"
APACHE_SERVER_NAME="${APACHE_SERVER_NAME:-}"
SSL_EMAIL="${SSL_EMAIL:-}"
SSL_DOMAIN="${SSL_DOMAIN:-}"
MYSQL_ROOT_PASS="${MYSQL_ROOT_PASS:-}"
# Validate database variables contain only safe characters (alphanumeric, underscore, hyphen, dot)
validate_db_vars() {
local var_name="$1"
local var_value="$2"
local pattern='^[a-zA-Z0-9_\-\.]+$'
if [[ ! "$var_value" =~ $pattern ]]; then
echo -e "${COLOR_RED}Error: ${var_name} contains invalid characters. Only alphanumeric, underscore, hyphen, and dot are allowed.${COLOR_RESET}"
exit 1
fi
}
# Validate critical database variables
validate_db_vars "DB_NAME" "$DB_NAME"
validate_db_vars "DB_USER" "$DB_USER"
validate_db_vars "DB_HOST" "$DB_HOST"
# Check if running interactively
INTERACTIVE=false
if [ -t 0 ]; then
INTERACTIVE=true
fi
echo -e "${COLOR_YELLOW}Configuration:${COLOR_RESET}"
echo -e " Database Name: ${DB_NAME}"
echo -e " Database User: ${DB_USER}"
echo -e " Database Host: ${DB_HOST}"
echo -e " Install Directory: ${OSPOS_DIR}"
echo -e " PHP Version: ${PHP_VERSION}"
if [ -n "$OSPOS_VERSION" ]; then
echo -e " OSPOS Version: ${OSPOS_VERSION}"
else
echo -e " OSPOS Version: latest"
fi
if [ -n "$APACHE_SERVER_NAME" ]; then
echo -e " Server Name: ${APACHE_SERVER_NAME}"
fi
echo ""
if [ -d "$OSPOS_DIR" ]; then
echo -e "${COLOR_RED}Installation directory $OSPOS_DIR already exists${COLOR_RESET}"
echo -e "${COLOR_YELLOW}Remove it or set OSPOS_DIR environment variable${COLOR_RESET}"
exit 1
fi
echo -e "${COLOR_GREEN}[1/9] Updating system packages...${COLOR_RESET}"
apt-get update -qq
echo -e "${COLOR_GREEN}[2/9] Installing Apache, PHP, and dependencies...${COLOR_RESET}"
# Add PHP repository for newer PHP versions if not available in default repos
if ! apt-cache policy php${PHP_VERSION} 2>/dev/null | grep -q "Candidate:"; then
echo -e "${COLOR_YELLOW}PHP ${PHP_VERSION} not in default repos, adding ondrej/php PPA...${COLOR_RESET}"
apt-get install -y -qq software-properties-common
add-apt-repository -y ppa:ondrej/php
apt-get update -qq
fi
apt-get install -y -qq \
apache2 \
mariadb-server \
mariadb-client \
php${PHP_VERSION} \
php${PHP_VERSION}-mysql \
php${PHP_VERSION}-gd \
php${PHP_VERSION}-bcmath \
php${PHP_VERSION}-intl \
php${PHP_VERSION}-mbstring \
php${PHP_VERSION}-curl \
php${PHP_VERSION}-xml \
php${PHP_VERSION}-zip \
git \
curl \
unzip \
openssl
echo -e "${COLOR_GREEN}[3/9] Starting MariaDB...${COLOR_RESET}"
systemctl start mariadb
systemctl enable mariadb
if [ -z "$MYSQL_ROOT_PASS" ]; then
echo -e "${COLOR_BLUE}Securing MariaDB installation...${COLOR_RESET}"
mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '';"
mysql -e "FLUSH PRIVILEGES;"
else
mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '${MYSQL_ROOT_PASS}';"
fi
echo -e "${COLOR_GREEN}[4/9] Creating database and user...${COLOR_RESET}"
if [ -n "$MYSQL_ROOT_PASS" ]; then
mysql -u root -p"${MYSQL_ROOT_PASS}" <<EOF
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'${DB_HOST}' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'${DB_HOST}';
FLUSH PRIVILEGES;
EOF
else
mysql -u root <<EOF
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'${DB_HOST}' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'${DB_HOST}';
FLUSH PRIVILEGES;
EOF
fi
echo -e "${COLOR_GREEN}[5/9] Downloading OSPOS...${COLOR_RESET}"
mkdir -p "$(dirname "$OSPOS_DIR")"
cd "$(dirname "$OSPOS_DIR")"
if [ -z "$OSPOS_VERSION" ]; then
OSPOS_VERSION=$(curl -sS https://api.github.com/repos/opensourcepos/opensourcepos/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$OSPOS_VERSION" ]; then
echo -e "${COLOR_RED}Failed to get latest release version${COLOR_RESET}"
exit 1
fi
fi
echo -e "${COLOR_BLUE}Downloading OSPOS version ${OSPOS_VERSION}...${COLOR_RESET}"
ASSET_URL=$(curl -sS "https://api.github.com/repos/opensourcepos/opensourcepos/releases/tags/${OSPOS_VERSION}" | grep '"browser_download_url"' | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$ASSET_URL" ]; then
echo -e "${COLOR_RED}Failed to find release asset for ${OSPOS_VERSION}${COLOR_RESET}"
exit 1
fi
curl -sSL "$ASSET_URL" -o ospos.zip
if [ ! -f ospos.zip ] || [ ! -s ospos.zip ]; then
echo -e "${COLOR_RED}Failed to download OSPOS release ${OSPOS_VERSION}${COLOR_RESET}"
rm -f ospos.zip
exit 1
fi
unzip -q ospos.zip -d ospos-temp
mkdir -p "${OSPOS_DIR}"
cp -r ospos-temp/. "${OSPOS_DIR}/"
rm -rf ospos-temp ospos.zip
echo -e "${COLOR_GREEN}Downloaded OSPOS ${OSPOS_VERSION}${COLOR_RESET}"
echo -e "${COLOR_GREEN}[6/9] Setting up OSPOS...${COLOR_RESET}"
cd "${OSPOS_DIR}"
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer 2>/dev/null
if [ -f "composer.json" ]; then
echo -e "${COLOR_BLUE}Installing dependencies...${COLOR_RESET}"
composer install --no-dev --optimize-autoloader --no-interaction --quiet 2>/dev/null
fi
echo -e "${COLOR_GREEN}[7/9] Configuring OSPOS...${COLOR_RESET}"
if [ -f ".env.example" ]; then
cp .env.example .env
fi
if [ -f ".env" ]; then
# Escape special characters in password for sed
ESCAPED_DB_PASS=$(printf '%s\n' "$DB_PASS" | sed 's/[&/\]/\\&/g')
sed -i "s|database\.default\.hostname = 'localhost'|database.default.hostname = '${DB_HOST}'|" .env
sed -i "s|database\.default\.database = 'ospos'|database.default.database = '${DB_NAME}'|" .env
sed -i "s|database\.default\.username = 'admin'|database.default.username = '${DB_USER}'|" .env
sed -i "s|database\.default\.password = 'pointofsale'|database.default.password = '${ESCAPED_DB_PASS}'|" .env
sed -i "s|CI_ENVIRONMENT = development|CI_ENVIRONMENT = production|" .env
if grep -q "encryption\.key = ''" .env; then
ENCRYPTION_KEY=$(openssl rand -base64 32)
ESCAPED_KEY=$(printf '%s\n' "$ENCRYPTION_KEY" | sed 's/[&/\]/\\&/g')
sed -i "s|encryption\.key = ''|encryption.key = '${ESCAPED_KEY}'|" .env
fi
fi
echo -e "${COLOR_GREEN}[8/9] Importing database schema...${COLOR_RESET}"
if [ -n "$MYSQL_ROOT_PASS" ]; then
mysql -u root -p"${MYSQL_ROOT_PASS}" ${DB_NAME} < app/Database/database.sql
else
mysql -u root ${DB_NAME} < app/Database/database.sql
fi
# Interactive SSL configuration
if $INTERACTIVE && [ -z "$SSL_EMAIL" ] && [ -z "$APACHE_SERVER_NAME" ]; then
echo ""
echo -e "${COLOR_BLUE}╔══════════════════════════════════════════════════════════╗${COLOR_RESET}"
echo -e "${COLOR_BLUE}║ SSL/TLS Configuration ║${COLOR_RESET}"
echo -e "${COLOR_BLUE}╚══════════════════════════════════════════════════════════╝${COLOR_RESET}"
echo ""
echo -e "${COLOR_YELLOW}SSL provides secure HTTPS access to your OSPOS installation.${COLOR_RESET}"
echo -e "${COLOR_YELLOW}For production, we recommend Let's Encrypt (free SSL certificate).${COLOR_RESET}"
echo ""
read -p "Configure SSL? (y/n) [n]: " CONFIGURE_SSL
CONFIGURE_SSL=${CONFIGURE_SSL:-n}
if [[ "$CONFIGURE_SSL" =~ ^[Yy]$ ]]; then
read -p "Enter your domain name (e.g., pos.example.com): " SSL_DOMAIN
SSL_DOMAIN=${SSL_DOMAIN:-localhost}
APACHE_SERVER_NAME=$SSL_DOMAIN
read -p "Enter your email for Let's Encrypt notifications: " SSL_EMAIL
if [ -z "$SSL_EMAIL" ]; then
echo -e "${COLOR_YELLOW}No email provided. Using self-signed certificate (not recommended for production).${COLOR_RESET}"
SSL_TYPE="self-signed"
else
SSL_TYPE="letsencrypt"
fi
else
APACHE_SERVER_NAME="localhost"
SSL_TYPE="none"
fi
fi
# Set default server name if not provided
if [ -z "$APACHE_SERVER_NAME" ]; then
APACHE_SERVER_NAME="localhost"
fi
# If SSL_EMAIL is set without SSL_DOMAIN, use APACHE_SERVER_NAME
if [ -n "$SSL_EMAIL" ] && [ -z "$SSL_DOMAIN" ] && [ "$APACHE_SERVER_NAME" != "localhost" ]; then
SSL_DOMAIN="$APACHE_SERVER_NAME"
fi
echo -e "${COLOR_GREEN}[9/9] Configuring Apache...${COLOR_RESET}"
cat > /etc/apache2/sites-available/ospos.conf <<EOF
<VirtualHost *:80>
ServerName ${APACHE_SERVER_NAME}
DocumentRoot ${OSPOS_DIR}/public
<Directory ${OSPOS_DIR}/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog \${APACHE_LOG_DIR}/ospos_error.log
CustomLog \${APACHE_LOG_DIR}/ospos_access.log combined
</VirtualHost>
EOF
a2enmod rewrite
a2dissite 000-default.conf
a2ensite ospos.conf
chown -R www-data:www-data "${OSPOS_DIR}"
chmod -R 750 "${OSPOS_DIR}/writable"
systemctl restart apache2
systemctl enable apache2
# Configure SSL if requested
if [ -n "$SSL_EMAIL" ] && [ -n "$SSL_DOMAIN" ]; then
# Let's Encrypt SSL
echo -e "${COLOR_BLUE}Installing Certbot for Let's Encrypt...${COLOR_RESET}"
apt-get install -y -qq certbot python3-certbot-apache
echo -e "${COLOR_BLUE}Obtaining SSL certificate for ${SSL_DOMAIN}...${COLOR_RESET}"
certbot --apache -d ${SSL_DOMAIN} --non-interactive --agree-tos --email ${SSL_EMAIL} --redirect
echo -e "${COLOR_BLUE}Setting up auto-renewal...${COLOR_RESET}"
systemctl enable certbot.timer
systemctl start certbot.timer
PROTOCOL="https"
FINAL_URL="https://${SSL_DOMAIN}/"
elif [ -n "$SSL_DOMAIN" ]; then
# Self-signed SSL
echo -e "${COLOR_BLUE}Generating self-signed SSL certificate...${COLOR_RESET}"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/ssl/private/ospos-selfsigned.key \
-out /etc/ssl/certs/ospos-selfsigned.crt \
-subj "/C=US/ST=State/L=City/O=Organization/CN=${SSL_DOMAIN}" 2>/dev/null
cat > /etc/apache2/sites-available/ospos-ssl.conf <<EOF
<VirtualHost *:443>
ServerName ${SSL_DOMAIN}
DocumentRoot ${OSPOS_DIR}/public
SSLEngine on
SSLCertificateFile /etc/ssl/certs/ospos-selfsigned.crt
SSLCertificateKeyFile /etc/ssl/private/ospos-selfsigned.key
<Directory ${OSPOS_DIR}/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog \${APACHE_LOG_DIR}/ospos_ssl_error.log
CustomLog \${APACHE_LOG_DIR}/ospos_ssl_access.log combined
</VirtualHost>
EOF
a2enmod ssl
a2ensite ospos-ssl.conf
cat > /etc/apache2/sites-available/ospos.conf <<EOF
<VirtualHost *:80>
ServerName ${SSL_DOMAIN}
Redirect permanent / https://${SSL_DOMAIN}/
</VirtualHost>
EOF
a2dissite ospos.conf
a2ensite ospos.conf
PROTOCOL="https"
FINAL_URL="https://${SSL_DOMAIN}/"
echo -e "${COLOR_YELLOW}Note: Your browser will show a security warning for self-signed${COLOR_RESET}"
echo -e "${COLOR_YELLOW} certificates. For production, re-run with an email for Let's Encrypt.${COLOR_RESET}"
else
PROTOCOL="http"
FINAL_URL="http://${APACHE_SERVER_NAME}/"
fi
systemctl restart apache2
# Configure allowed hostnames
if [ -f "${OSPOS_DIR}/.env" ]; then
sed -i "s|app\.allowedHostnames = ''|app.allowedHostnames = '${APACHE_SERVER_NAME}'|" ${OSPOS_DIR}/.env
fi
echo ""
echo -e "${COLOR_GREEN}╔══════════════════════════════════════════════════════════╗${COLOR_RESET}"
echo -e "${COLOR_GREEN}║ Installation Complete! ║${COLOR_RESET}"
echo -e "${COLOR_GREEN}╚══════════════════════════════════════════════════════════╝${COLOR_RESET}"
echo ""
echo -e "${COLOR_YELLOW}Database Credentials:${COLOR_RESET}"
echo -e " Database: ${DB_NAME}"
echo -e " Username: ${DB_USER}"
echo -e " Password: ${DB_PASS}"
echo ""
echo -e "${COLOR_YELLOW}Login Credentials:${COLOR_RESET}"
echo -e " URL: ${FINAL_URL}"
if [ -n "$SSL_EMAIL" ]; then
echo -e " SSL: Let's Encrypt (auto-renewal enabled)"
elif [ -n "$SSL_DOMAIN" ]; then
echo -e " SSL: Self-signed certificate"
else
echo -e " SSL: Not configured (HTTP only)"
fi
echo -e " Username: admin"
echo -e " Password: pointofsale"
echo ""
echo -e "${COLOR_RED}IMPORTANT: Change the default password after first login!${COLOR_RESET}"
echo ""
echo -e "${COLOR_BLUE}Configuration file: ${OSPOS_DIR}/.env${COLOR_RESET}"
echo ""