Compare commits
77 Commits
development
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9df6bea4b7 | |||
| ef77ae83da | |||
| 1fd90366d6 | |||
| 814b6879da | |||
| 9d67a045f3 | |||
| f4f47ad43a | |||
| c096172c2c | |||
| 35dac96d7a | |||
| fc61577422 | |||
| edda6b7a51 | |||
| c9df811602 | |||
| ff21765973 | |||
| 81d2f1ceea | |||
| d772f10b6a | |||
| 91edc73336 | |||
| 7f1bcb23bf | |||
| bdb135bcb4 | |||
| 1781ee8c67 | |||
| 803464a584 | |||
| 345483c6f9 | |||
| 19bbe92780 | |||
| 3aa1b43e96 | |||
| 27d26f15ca | |||
| a8d1e8f276 | |||
| f143a959be | |||
| 5e889bbcff | |||
| c46373265d | |||
| 8044106f19 | |||
| 291f04eb81 | |||
| dca4ef89a9 | |||
| ffa9edd33f | |||
| 70fe78e064 | |||
| ceb3cfacf7 | |||
| 6ecaf9923d | |||
| e8e8c689e8 | |||
| d26ada7d18 | |||
| f6e7082f44 | |||
| 5c048ef5db | |||
| 10b597b248 | |||
| 7f272aabf9 | |||
| 44030cdc9c | |||
| e21e345389 | |||
| 43646e826d | |||
| 91542cf759 | |||
| c4a77e2da7 | |||
| 156cb1713f | |||
| 59f37f09cf | |||
| 1308497e39 | |||
| 481893e182 | |||
| 8606acf2fd | |||
| 71a9da3f72 | |||
| c42b65ed38 | |||
| 9949bf7fda | |||
| 44ca197c36 | |||
| 706c088da1 | |||
| 1ebba18c16 | |||
| 70c2aaae05 | |||
| 9085ccf474 | |||
| 9d22ba0b10 | |||
| f03a522bb9 | |||
| 344673ab8a | |||
| 43b8549402 | |||
| a4f55f6ba7 | |||
| 222a52580c | |||
| 1c6c8a8473 | |||
| 1964c86ee0 | |||
| cd7bdc03c8 | |||
| 9d8fd4eed1 | |||
| 1404b699ad | |||
| b1d72bc23e | |||
| 67721d0247 | |||
| 7687da58c3 | |||
| a4d9d6d129 | |||
| 495083f89f | |||
| f47554e46c | |||
| eddc9c2fd4 | |||
| b88e68ee10 |
@@ -38,7 +38,7 @@ Joomla **package** (`pkg_mokosuiteclient`) with 17 sub-extensions:
|
|||||||
|
|
||||||
### Component (`com_mokosuiteclient`)
|
### Component (`com_mokosuiteclient`)
|
||||||
- Admin dashboard with plugin management, WAF charts, extension catalog
|
- Admin dashboard with plugin management, WAF charts, extension catalog
|
||||||
- Content tools: snippets, templates, replacements, conditions, articles anywhere, users anywhere
|
- Helpdesk ticketing system
|
||||||
- REST API controllers
|
- REST API controllers
|
||||||
|
|
||||||
### Modules
|
### Modules
|
||||||
@@ -50,6 +50,7 @@ Joomla **package** (`pkg_mokosuiteclient`) with 17 sub-extensions:
|
|||||||
### Task Plugins
|
### Task Plugins
|
||||||
- `plg_task_mokosuiteclientdemo` — scheduled demo site reset
|
- `plg_task_mokosuiteclientdemo` — scheduled demo site reset
|
||||||
- `plg_task_mokosuiteclientsync` — scheduled content sync
|
- `plg_task_mokosuiteclientsync` — scheduled content sync
|
||||||
|
- `plg_task_mokosuiteclient_tickets` — ticket automation
|
||||||
|
|
||||||
### Update Server
|
### Update Server
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
# VERSION: 05.00.00
|
# VERSION: 05.00.00
|
||||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||||
#
|
#
|
||||||
# +========================================================================+
|
# +=======================================================================+
|
||||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||||
# +========================================================================+
|
# +=======================================================================+
|
||||||
# | |
|
# | |
|
||||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||||
# | |
|
# | |
|
||||||
@@ -21,15 +21,24 @@
|
|||||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||||
# | generic: README-only, no update stream |
|
# | generic: README-only, no update stream |
|
||||||
# | |
|
# | |
|
||||||
# +========================================================================+
|
# +=======================================================================+
|
||||||
|
|
||||||
name: "Universal: Build & Release"
|
name: "Universal: Build & Release"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, closed]
|
types: [opened, synchronize, closed]
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '.mokogitea/workflows/**'
|
||||||
|
- '*.md'
|
||||||
|
- 'wiki/**'
|
||||||
|
- '.editorconfig'
|
||||||
|
- '.gitignore'
|
||||||
|
- '.gitattributes'
|
||||||
|
- '.gitmessage'
|
||||||
|
- 'LICENSE'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
action:
|
action:
|
||||||
@@ -43,7 +52,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||||
|
|
||||||
@@ -51,12 +60,13 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
|
||||||
promote-rc:
|
promote-rc:
|
||||||
name: Promote to RC
|
name: Promote to RC
|
||||||
runs-on: release
|
runs-on: release
|
||||||
if: >-
|
if: >-
|
||||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||||
|
(github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -92,7 +102,7 @@ jobs:
|
|||||||
php ${MOKO_CLI}/branch_rename.php \
|
php ${MOKO_CLI}/branch_rename.php \
|
||||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
--api-base "${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||||
--pr "${{ github.event.pull_request.number }}"
|
--pr "${{ github.event.pull_request.number }}"
|
||||||
|
|
||||||
- name: Checkout rc and configure git
|
- name: Checkout rc and configure git
|
||||||
@@ -111,7 +121,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Update RC release notes from CHANGELOG.md
|
- name: Update RC release notes from CHANGELOG.md
|
||||||
run: |
|
run: |
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
# Extract [Unreleased] section from changelog
|
# Extract [Unreleased] section from changelog
|
||||||
@@ -149,7 +159,7 @@ jobs:
|
|||||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
# ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
|
||||||
release:
|
release:
|
||||||
name: Build & Release Pipeline
|
name: Build & Release Pipeline
|
||||||
runs-on: release
|
runs-on: release
|
||||||
@@ -205,6 +215,12 @@ jobs:
|
|||||||
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: "Detect platform"
|
||||||
|
id: platform
|
||||||
|
run: |
|
||||||
|
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||||
|
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
|
||||||
|
|
||||||
- name: "Determine version bump level"
|
- name: "Determine version bump level"
|
||||||
id: bump
|
id: bump
|
||||||
run: |
|
run: |
|
||||||
@@ -228,9 +244,57 @@ jobs:
|
|||||||
--path . --stability stable ${BUMP_FLAG} --branch main \
|
--path . --stability stable ${BUMP_FLAG} --branch main \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
|
- name: "Read published version"
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
|
||||||
|
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||||
|
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
|
if [[ "$PLATFORM" == joomla* ]]; then
|
||||||
|
echo "tag=stable" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
echo "branch=main" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Published version: ${VERSION}"
|
||||||
|
|
||||||
|
- name: "Create semver tag for non-Joomla repos"
|
||||||
|
id: semver
|
||||||
|
if: |
|
||||||
|
steps.version.outputs.skip != 'true' &&
|
||||||
|
!startsWith(steps.platform.outputs.platform, 'joomla')
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
SEMVER_TAG="v${VERSION}"
|
||||||
|
|
||||||
|
echo "Creating semver tag: ${SEMVER_TAG}"
|
||||||
|
|
||||||
|
# Create the git tag via API
|
||||||
|
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||||
|
-X POST -H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API_BASE}/tags" \
|
||||||
|
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo "Created semver tag: ${SEMVER_TAG}"
|
||||||
|
elif [ "$HTTP_CODE" = "409" ]; then
|
||||||
|
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
|
||||||
|
else
|
||||||
|
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Update release notes and promote changelog
|
- name: Update release notes and promote changelog
|
||||||
run: |
|
run: |
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
# Get the stable release info (version and ID)
|
# Get the stable release info (version and ID)
|
||||||
@@ -299,7 +363,7 @@ jobs:
|
|||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
php ${MOKO_CLI}/release_mirror.php \
|
php ${MOKO_CLI}/release_mirror.php \
|
||||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
@@ -328,7 +392,7 @@ jobs:
|
|||||||
if: steps.version.outputs.skip != 'true'
|
if: steps.version.outputs.skip != 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
# Delete rc branch (ephemeral — created by promote-rc)
|
# Delete rc branch (ephemeral — created by promote-rc)
|
||||||
@@ -352,7 +416,7 @@ jobs:
|
|||||||
if: steps.version.outputs.skip != 'true'
|
if: steps.version.outputs.skip != 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||||
BRANCH_NAME="version/${VERSION}"
|
BRANCH_NAME="version/${VERSION}"
|
||||||
@@ -373,7 +437,7 @@ jobs:
|
|||||||
if: steps.version.outputs.skip != 'true'
|
if: steps.version.outputs.skip != 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
php ${MOKO_CLI}/version_reset_dev.php \
|
php ${MOKO_CLI}/version_reset_dev.php \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||||
--branch dev --path . 2>&1 || true
|
--branch dev --path . 2>&1 || true
|
||||||
@@ -399,5 +463,5 @@ jobs:
|
|||||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
echo "| Release | [View](${MOKOGITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -13,12 +13,6 @@
|
|||||||
name: "Generic: Project CI"
|
name: "Generic: Project CI"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- dev
|
|
||||||
- dev/**
|
|
||||||
- rc/**
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: mokocli.Universal
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
|
# PATH: /.mokogitea/workflows/ci-issue-reporter.yml
|
||||||
|
# VERSION: 01.00.00
|
||||||
|
# BRIEF: Reusable workflow — creates/updates a Gitea issue when a CI gate fails.
|
||||||
|
# Clones MokoCLI and runs cli/ci_issue_reporter.sh.
|
||||||
|
|
||||||
|
name: "Universal: CI Issue Reporter"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
gate:
|
||||||
|
description: "CI gate name (e.g. PR Validation, Repository Health)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
details:
|
||||||
|
description: "Human-readable failure description"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
severity:
|
||||||
|
description: "error or warning"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: "error"
|
||||||
|
workflow:
|
||||||
|
description: "Workflow name for the issue title"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
secrets:
|
||||||
|
MOKOGITEA_TOKEN:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
report:
|
||||||
|
name: "Report: ${{ inputs.gate }}"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Clone MokoCLI
|
||||||
|
env:
|
||||||
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||||
|
git clone --depth 1 --filter=blob:none --sparse "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli
|
||||||
|
cd /tmp/mokocli && git sparse-checkout set cli/ci_issue_reporter.sh
|
||||||
|
|
||||||
|
- name: Report CI failure
|
||||||
|
env:
|
||||||
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
run: |
|
||||||
|
chmod +x /tmp/mokocli/cli/ci_issue_reporter.sh
|
||||||
|
/tmp/mokocli/cli/ci_issue_reporter.sh \
|
||||||
|
--gate "${{ inputs.gate }}" \
|
||||||
|
--details "${{ inputs.details }}" \
|
||||||
|
--severity "${{ inputs.severity }}" \
|
||||||
|
--workflow "${{ inputs.workflow }}"
|
||||||
@@ -45,17 +45,17 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
php -v && composer --version
|
php -v && composer --version
|
||||||
|
|
||||||
- name: Setup moko-platform tools
|
- name: Setup mokocli tools
|
||||||
env:
|
env:
|
||||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || github.token }}
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||||
run: |
|
run: |
|
||||||
if [ -d "/tmp/moko-platform" ] || [ -d "/opt/moko-platform" ]; then
|
if [ -d "/opt/mokocli" ] || [ -d "/tmp/mokocli" ]; then
|
||||||
echo "moko-platform already available on runner — skipping clone"
|
echo "mokocli already available on runner — skipping clone"
|
||||||
else
|
else
|
||||||
git clone --depth 1 --branch main --quiet \
|
git clone --depth 1 --branch main --quiet \
|
||||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \
|
||||||
/tmp/moko-platform 2>/dev/null || echo "moko-platform clone skipped — continuing without it"
|
/tmp/mokocli 2>/dev/null || echo "mokocli clone skipped — continuing without it"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -245,10 +245,413 @@ jobs:
|
|||||||
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
|
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Check config.xml and access.xml for components
|
||||||
|
run: |
|
||||||
|
echo "### Component Config & ACL Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
# Find all component manifests (XML with type="component")
|
||||||
|
COMP_MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<extension[^>]*type="component"' {} ; 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -z "$COMP_MANIFESTS" ]; then
|
||||||
|
echo "No component extensions found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
for MANIFEST in $COMP_MANIFESTS; do
|
||||||
|
COMP_DIR=$(dirname "$MANIFEST")
|
||||||
|
COMP_NAME=$(basename "$COMP_DIR")
|
||||||
|
echo "Component: `${COMP_NAME}` (manifest: `${MANIFEST}`)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# Check access.xml exists
|
||||||
|
ACCESS_FILE=$(find "$COMP_DIR" -name "access.xml" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||||
|
if [ -z "$ACCESS_FILE" ]; then
|
||||||
|
echo "- Missing `access.xml` — ACL permissions will not work." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
if command -v php &> /dev/null; then
|
||||||
|
if ! php -r "@simplexml_load_file('$ACCESS_FILE') ?: exit(1);" 2>/dev/null; then
|
||||||
|
echo "- `access.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
for ACTION in core.admin core.manage; do
|
||||||
|
if ! grep -q "name=\"${ACTION}\"" "$ACCESS_FILE" 2>/dev/null; then
|
||||||
|
echo "- `access.xml` missing required action: `${ACTION}`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "- `access.xml`: valid" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check config.xml exists
|
||||||
|
CONFIG_FILE=$(find "$COMP_DIR" -name "config.xml" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||||
|
if [ -z "$CONFIG_FILE" ]; then
|
||||||
|
echo "- Missing `config.xml` — component Options page will be empty." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
if command -v php &> /dev/null; then
|
||||||
|
if ! php -r "@simplexml_load_file('$CONFIG_FILE') ?: exit(1);" 2>/dev/null; then
|
||||||
|
echo "- `config.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "- `config.xml`: valid" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} config/ACL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Component config & ACL check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: SQL schema validation
|
||||||
|
run: |
|
||||||
|
echo "### SQL Schema Validation" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
# Find SQL files in source/htdocs
|
||||||
|
SQL_FILES=$(find . -name "*.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||||
|
if [ -z "$SQL_FILES" ]; then
|
||||||
|
echo "No SQL files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "Found $(echo "$SQL_FILES" | wc -l) SQL file(s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
for FILE in $SQL_FILES; do
|
||||||
|
# Basic syntax check: balanced parentheses, no empty files
|
||||||
|
SIZE=$(wc -c < "$FILE" | tr -d ' ')
|
||||||
|
if [ "$SIZE" -eq 0 ]; then
|
||||||
|
echo "- Empty SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for common SQL errors
|
||||||
|
if grep -qP '^\s*$' "$FILE" && [ "$SIZE" -lt 5 ]; then
|
||||||
|
echo "- Whitespace-only SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "- \`${FILE}\`: ${SIZE} bytes" >> $GITHUB_STEP_SUMMARY
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check update SQL files follow version numbering pattern
|
||||||
|
UPDATE_DIR=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||||
|
if [ -n "$UPDATE_DIR" ]; then
|
||||||
|
BAD_NAMES=0
|
||||||
|
for UFILE in "$UPDATE_DIR"/*.sql; do
|
||||||
|
[ ! -f "$UFILE" ] && continue
|
||||||
|
BASENAME=$(basename "$UFILE" .sql)
|
||||||
|
if ! echo "$BASENAME" | grep -qP '^\d+\.\d+\.\d+'; then
|
||||||
|
echo "- Update file \`${UFILE}\` does not follow version naming (expected X.Y.Z.sql)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
BAD_NAMES=$((BAD_NAMES + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$BAD_NAMES" -gt 0 ]; then
|
||||||
|
ERRORS=$((ERRORS + BAD_NAMES))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} SQL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**SQL schema validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Manifest file references check
|
||||||
|
run: |
|
||||||
|
echo "### Manifest File References" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
MANIFEST=""
|
||||||
|
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||||
|
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||||
|
MANIFEST="$XML_FILE"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$MANIFEST" ]; then
|
||||||
|
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||||
|
|
||||||
|
# Check <filename> references
|
||||||
|
FILENAMES=$(grep -oP '<filename[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||||
|
for F in $FILENAMES; do
|
||||||
|
if [ ! -f "${MANIFEST_DIR}/${F}" ] && [ ! -d "${MANIFEST_DIR}/${F}" ]; then
|
||||||
|
echo "- Missing: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check <folder> references
|
||||||
|
FOLDERS=$(grep -oP '<folder[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||||
|
for F in $FOLDERS; do
|
||||||
|
if [ ! -d "${MANIFEST_DIR}/${F}" ]; then
|
||||||
|
echo "- Missing folder: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check <file> references in package manifests (ZIP files won't exist in source)
|
||||||
|
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
|
||||||
|
if [ "$EXT_TYPE" != "package" ]; then
|
||||||
|
FILES=$(grep -oP '<file[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
|
||||||
|
for F in $FILES; do
|
||||||
|
if [ ! -f "${MANIFEST_DIR}/${F}" ]; then
|
||||||
|
echo "- Missing file: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} missing file reference(s).**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Manifest file references check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Form XML validation
|
||||||
|
run: |
|
||||||
|
echo "### Form XML Validation" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
FORM_FILES=$(find . -name "*.xml" -path "*/forms/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||||
|
if [ -z "$FORM_FILES" ]; then
|
||||||
|
echo "No form XML files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "Found $(echo "$FORM_FILES" | wc -l) form file(s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
for FILE in $FORM_FILES; do
|
||||||
|
if command -v php &> /dev/null; then
|
||||||
|
if ! php -r "@simplexml_load_file('$FILE') ?: exit(1);" 2>/dev/null; then
|
||||||
|
echo "- \`${FILE}\`: malformed XML" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
# Check for valid Joomla form structure
|
||||||
|
if ! grep -qE '<form|<field|<fieldset' "$FILE" 2>/dev/null; then
|
||||||
|
echo "- \`${FILE}\`: no \`<form>\`, \`<field>\`, or \`<fieldset>\` elements found" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "- \`${FILE}\`: valid" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} form XML issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Form XML validation passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deprecated Joomla API check
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
echo "### Deprecated Joomla API Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
WARNINGS=0
|
||||||
|
|
||||||
|
SRC_DIR=""
|
||||||
|
for DIR in source/ src/ htdocs/; do
|
||||||
|
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$SRC_DIR" ]; then
|
||||||
|
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
# Joomla 3/4 deprecated patterns that break in Joomla 6
|
||||||
|
PATTERNS=(
|
||||||
|
'JFactory::'
|
||||||
|
'JText::'
|
||||||
|
'JHtml::'
|
||||||
|
'JRoute::'
|
||||||
|
'JUri::'
|
||||||
|
'JLog::'
|
||||||
|
'JTable::'
|
||||||
|
'JInput'
|
||||||
|
'CMSFactory::\$application'
|
||||||
|
'JApplicationCms'
|
||||||
|
)
|
||||||
|
|
||||||
|
for PATTERN in "${PATTERNS[@]}"; do
|
||||||
|
HITS=$(grep -rnl "$PATTERN" "$SRC_DIR" --include="*.php" 2>/dev/null || true)
|
||||||
|
if [ -n "$HITS" ]; then
|
||||||
|
COUNT=$(echo "$HITS" | wc -l)
|
||||||
|
echo "- \`${PATTERN}\` found in ${COUNT} file(s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
WARNINGS=$((WARNINGS + COUNT))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "$WARNINGS" -gt 0 ]; then
|
||||||
|
echo "**${WARNINGS} deprecated API usage(s) found.** These will break in Joomla 6." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "**No deprecated APIs found.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Template output escaping check
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
echo "### Template Output Escaping" >> $GITHUB_STEP_SUMMARY
|
||||||
|
WARNINGS=0
|
||||||
|
|
||||||
|
TMPL_FILES=$(find . -name "*.php" -path "*/tmpl/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||||
|
if [ -z "$TMPL_FILES" ]; then
|
||||||
|
echo "No template files found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "Found $(echo "$TMPL_FILES" | wc -l) template file(s)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
for FILE in $TMPL_FILES; do
|
||||||
|
# Check for unescaped output: <?= $var ?> or echo $var without escape()
|
||||||
|
UNESCAPED=$(grep -nP '<\?=\s*\$(?!this->escape)' "$FILE" 2>/dev/null || true)
|
||||||
|
if [ -n "$UNESCAPED" ]; then
|
||||||
|
HITS=$(echo "$UNESCAPED" | wc -l)
|
||||||
|
echo "- \`${FILE}\`: ${HITS} unescaped \`<?= \$var ?>\` output(s) — use \`<?= \$this->escape(\$var) ?>\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
WARNINGS=$((WARNINGS + HITS))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for echo without escaping in template context
|
||||||
|
RAW_ECHO=$(grep -nP '^\s*echo\s+\$(?!this->escape)' "$FILE" 2>/dev/null || true)
|
||||||
|
if [ -n "$RAW_ECHO" ]; then
|
||||||
|
HITS=$(echo "$RAW_ECHO" | wc -l)
|
||||||
|
echo "- \`${FILE}\`: ${HITS} raw \`echo \$var\` — consider \`echo \$this->escape(\$var)\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
WARNINGS=$((WARNINGS + HITS))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "$WARNINGS" -gt 0 ]; then
|
||||||
|
echo "**${WARNINGS} potential XSS risk(s) in templates.** Review unescaped output." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "**All template output appears properly escaped.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Namespace consistency check
|
||||||
|
run: |
|
||||||
|
echo "### Namespace Consistency" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
# Find component/plugin manifests with <namespace> tags
|
||||||
|
MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<namespace' {} \; 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -z "$MANIFESTS" ]; then
|
||||||
|
echo "No manifests with \`<namespace>\` found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
for MANIFEST in $MANIFESTS; do
|
||||||
|
NS_PATH=$(grep -oP '<namespace[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
|
||||||
|
[ -z "$NS_PATH" ] && continue
|
||||||
|
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||||
|
|
||||||
|
echo "Manifest: \`${MANIFEST}\` → namespace \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# Check PHP files have matching namespace
|
||||||
|
while IFS= read -r -d '' PHP_FILE; do
|
||||||
|
FILE_NS=$(grep -oP '^\s*namespace\s+\K[^;]+' "$PHP_FILE" 2>/dev/null | head -1)
|
||||||
|
[ -z "$FILE_NS" ] && continue
|
||||||
|
|
||||||
|
# Namespace should start with the manifest namespace path
|
||||||
|
if ! echo "$FILE_NS" | grep -qF "${NS_PATH}"; then
|
||||||
|
echo "- \`${PHP_FILE}\`: namespace \`${FILE_NS}\` doesn't match manifest \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$MANIFEST_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" -print0 2>/dev/null)
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} namespace mismatch(es).**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Namespace consistency check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: SPDX license header check
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
echo "### SPDX License Headers" >> $GITHUB_STEP_SUMMARY
|
||||||
|
MISSING=0
|
||||||
|
|
||||||
|
SRC_DIR=""
|
||||||
|
for DIR in source/ src/ htdocs/; do
|
||||||
|
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$SRC_DIR" ]; then
|
||||||
|
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
TOTAL=0
|
||||||
|
while IFS= read -r -d '' FILE; do
|
||||||
|
TOTAL=$((TOTAL + 1))
|
||||||
|
if ! head -10 "$FILE" | grep -qi "SPDX"; then
|
||||||
|
echo "- Missing SPDX header: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
MISSING=$((MISSING + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0)
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "$MISSING" -gt 0 ]; then
|
||||||
|
echo "**${MISSING}/${TOTAL} PHP file(s) missing SPDX license header.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "**All ${TOTAL} PHP files have SPDX headers.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Service provider check
|
||||||
|
run: |
|
||||||
|
echo "### Service Provider Check" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
PROVIDERS=$(find . -name "provider.php" -path "*/services/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
|
||||||
|
if [ -z "$PROVIDERS" ]; then
|
||||||
|
echo "No service providers found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
for FILE in $PROVIDERS; do
|
||||||
|
# Must return a ServiceProviderInterface
|
||||||
|
if ! grep -qP 'ServiceProviderInterface|ComponentInterface|MVCFactoryInterface|DispatcherInterface' "$FILE" 2>/dev/null; then
|
||||||
|
echo "- \`${FILE}\`: does not reference ServiceProviderInterface or component interfaces" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
else
|
||||||
|
echo "- \`${FILE}\`: valid service provider" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Must have return statement
|
||||||
|
if ! grep -qP '^\s*return\s+new\s+' "$FILE" 2>/dev/null; then
|
||||||
|
echo "- \`${FILE}\`: missing \`return new ...\` statement" >> $GITHUB_STEP_SUMMARY
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${ERRORS}" -gt 0 ]; then
|
||||||
|
echo "**${ERRORS} service provider issue(s).**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
release-readiness:
|
release-readiness:
|
||||||
name: Release Readiness Check
|
name: Release Readiness Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: moko-platform.CI
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
# PATH: /.gitea/workflows/ci-platform.yml
|
||||||
|
# VERSION: 09.23.00
|
||||||
|
# BRIEF: moko-platform CI — the standards engine validates itself
|
||||||
|
#
|
||||||
|
# +========================================================================+
|
||||||
|
# | MOKO-PLATFORM CI |
|
||||||
|
# +========================================================================+
|
||||||
|
# | |
|
||||||
|
# | This is NOT a generic CI workflow. This is the self-validation |
|
||||||
|
# | pipeline for the central moko-platform enterprise engine. |
|
||||||
|
# | |
|
||||||
|
# | It dogfoods every tool the platform ships to governed repos: |
|
||||||
|
# | |
|
||||||
|
# | Gate 1 — Code Quality phpcs (PSR-12), phpstan (L5), psalm |
|
||||||
|
# | Gate 2 — Unit Tests phpunit with coverage threshold |
|
||||||
|
# | Gate 3 — Self-Health bin/moko health against its own repo |
|
||||||
|
# | Gate 4 — Governance Checks headers, secrets, structure, versions |
|
||||||
|
# | Gate 5 — Template Lint validate workflow templates parse clean |
|
||||||
|
# | |
|
||||||
|
# | If it doesn't pass its own checks, it can't enforce them. |
|
||||||
|
# | |
|
||||||
|
# +========================================================================+
|
||||||
|
|
||||||
|
name: "Platform: moko-platform CI"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
- dev/**
|
||||||
|
- rc/**
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- 'wiki/**'
|
||||||
|
- '.gitea/ISSUE_TEMPLATE/**'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
- dev/**
|
||||||
|
- rc/**
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
full_suite:
|
||||||
|
description: 'Run full validation suite (including slow checks)'
|
||||||
|
required: false
|
||||||
|
default: 'true'
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-platform-${{ github.repository }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
PHP_VERSION: '8.2'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# Gate 1 — Code Quality
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
code-quality:
|
||||||
|
name: "Gate 1: Code Quality"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP ${{ env.PHP_VERSION }}
|
||||||
|
run: |
|
||||||
|
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
|
||||||
|
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl php${{ env.PHP_VERSION }}-zip \
|
||||||
|
php${{ env.PHP_VERSION }}-intl composer >/dev/null 2>&1
|
||||||
|
php -v
|
||||||
|
|
||||||
|
- name: Install Composer dependencies
|
||||||
|
run: |
|
||||||
|
composer install --no-interaction --prefer-dist
|
||||||
|
echo "Dependencies installed: $(composer show | wc -l) packages"
|
||||||
|
|
||||||
|
- name: "PHP Syntax Check"
|
||||||
|
run: |
|
||||||
|
ERRORS=0
|
||||||
|
CHECKED=0
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
CHECKED=$((CHECKED + 1))
|
||||||
|
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||||
|
echo "::error file=${file}::PHP syntax error"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find lib/ validate/ automation/ cli/ src/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "### PHP Syntax"
|
||||||
|
echo "Checked ${CHECKED} files — ${ERRORS} error(s)"
|
||||||
|
} >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
[ "$ERRORS" -eq 0 ] || exit 1
|
||||||
|
|
||||||
|
- name: "PHPCS (PSR-12)"
|
||||||
|
run: |
|
||||||
|
vendor/bin/phpcs --standard=phpcs.xml --report=summary --warning-severity=0 lib/ validate/ automation/ 2>&1 || {
|
||||||
|
echo "::error::PHPCS found coding standard violations"
|
||||||
|
echo "### PHPCS" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Coding standard violations detected. Run \`composer phpcs\` locally." >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "### PHPCS" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "PSR-12 compliance: passed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
- name: "PHPStan (Level 6)"
|
||||||
|
run: |
|
||||||
|
vendor/bin/phpstan analyse -c phpstan.neon --no-progress --memory-limit=512M --error-format=github 2>&1 || {
|
||||||
|
echo "::error::PHPStan found type errors"
|
||||||
|
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Static analysis errors detected. Run \`composer phpstan\` locally." >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Static analysis (level 6): passed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
- name: "Psalm"
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
if [ -f "psalm.xml" ]; then
|
||||||
|
vendor/bin/psalm --config=psalm.xml --no-progress --output-format=github 2>&1 || {
|
||||||
|
echo "### Psalm" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Psalm found issues (advisory — not blocking)." >> $GITHUB_STEP_SUMMARY
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# Gate 2 — Unit Tests
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
tests:
|
||||||
|
name: "Gate 2: Unit Tests"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
needs: code-quality
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
php: ['8.1', '8.2', '8.3']
|
||||||
|
fail-fast: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP ${{ matrix.php }}
|
||||||
|
run: |
|
||||||
|
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq php${{ matrix.php }}-cli php${{ matrix.php }}-mbstring \
|
||||||
|
php${{ matrix.php }}-xml php${{ matrix.php }}-curl php${{ matrix.php }}-zip \
|
||||||
|
php${{ matrix.php }}-intl composer >/dev/null 2>&1
|
||||||
|
php -v
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
|
|
||||||
|
- name: "PHPUnit (PHP ${{ matrix.php }})"
|
||||||
|
run: |
|
||||||
|
vendor/bin/phpunit --testdox 2>&1 || {
|
||||||
|
echo "::error::PHPUnit tests failed"
|
||||||
|
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Tests failed. Run \`vendor/bin/phpunit --testdox\` locally." >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "All tests passed." >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# Gate 3 — Self-Health (Dogfood)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
self-health:
|
||||||
|
name: "Gate 3: Self-Health Check"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
needs: code-quality
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
run: |
|
||||||
|
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
|
||||||
|
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl php${{ env.PHP_VERSION }}-zip \
|
||||||
|
composer >/dev/null 2>&1
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
|
|
||||||
|
- name: "Run bin/moko health against self"
|
||||||
|
run: |
|
||||||
|
php bin/moko health -- --path . --json > /tmp/health-report.json 2>&1 || true
|
||||||
|
SCORE=$(cat /tmp/health-report.json | python3 -c "import sys,json; print(json.load(sys.stdin).get('percentage', 0))" 2>/dev/null || echo "0")
|
||||||
|
LEVEL=$(cat /tmp/health-report.json | python3 -c "import sys,json; print(json.load(sys.stdin).get('level', 'unknown'))" 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "### Self-Health Report"
|
||||||
|
echo ""
|
||||||
|
echo "| Metric | Value |"
|
||||||
|
echo "|---|---|"
|
||||||
|
echo "| Score | ${SCORE}% |"
|
||||||
|
echo "| Level | ${LEVEL} |"
|
||||||
|
echo ""
|
||||||
|
echo "The platform must pass its own health check to enforce it on others."
|
||||||
|
} >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# Platform must score at least 80%
|
||||||
|
python3 -c "exit(0 if float('${SCORE}') >= 80.0 else 1)" || {
|
||||||
|
echo "::error::Self-health score ${SCORE}% is below 80% threshold"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# Gate 4 — Governance Checks
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
governance:
|
||||||
|
name: "Gate 4: Governance"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
needs: code-quality
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
run: |
|
||||||
|
sudo add-apt-repository -y ppa:ondrej/php >/dev/null 2>&1
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq php${{ env.PHP_VERSION }}-cli php${{ env.PHP_VERSION }}-mbstring \
|
||||||
|
php${{ env.PHP_VERSION }}-xml php${{ env.PHP_VERSION }}-curl composer >/dev/null 2>&1
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
|
|
||||||
|
- name: "License headers (SPDX)"
|
||||||
|
run: |
|
||||||
|
MISSING=0
|
||||||
|
CHECKED=0
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
CHECKED=$((CHECKED + 1))
|
||||||
|
if ! head -n 20 "$file" | grep -q "SPDX-License-Identifier:"; then
|
||||||
|
echo "::warning file=${file}::Missing SPDX header"
|
||||||
|
MISSING=$((MISSING + 1))
|
||||||
|
fi
|
||||||
|
done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "### License Headers"
|
||||||
|
echo "Checked ${CHECKED} files — ${MISSING} missing SPDX headers"
|
||||||
|
} >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# Advisory — warn but don't fail (yet)
|
||||||
|
[ "$MISSING" -eq 0 ] || echo "::warning::${MISSING} files missing SPDX license headers"
|
||||||
|
|
||||||
|
- name: "Secret detection"
|
||||||
|
run: |
|
||||||
|
FOUND=0
|
||||||
|
# Check for common secret patterns in source files
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
if grep -qEi '(password|secret|token|apikey|api_key)\s*[:=]\s*["\x27][^\s]{8,}' "$file" 2>/dev/null; then
|
||||||
|
echo "::error file=${file}::Potential hardcoded secret detected"
|
||||||
|
FOUND=$((FOUND + 1))
|
||||||
|
fi
|
||||||
|
done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "### Secret Detection"
|
||||||
|
if [ "$FOUND" -eq 0 ]; then
|
||||||
|
echo "No hardcoded secrets detected."
|
||||||
|
else
|
||||||
|
echo "${FOUND} potential secrets found."
|
||||||
|
fi
|
||||||
|
} >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
[ "$FOUND" -eq 0 ] || exit 1
|
||||||
|
|
||||||
|
- name: "Version consistency"
|
||||||
|
run: |
|
||||||
|
# Extract version from composer.json
|
||||||
|
COMPOSER_VER=$(python3 -c "import json; print(json.load(open('composer.json'))['version'])")
|
||||||
|
# Extract version from README.md
|
||||||
|
README_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "### Version Consistency"
|
||||||
|
echo "| Source | Version |"
|
||||||
|
echo "|---|---|"
|
||||||
|
echo "| composer.json | ${COMPOSER_VER} |"
|
||||||
|
echo "| README.md | ${README_VER:-not found} |"
|
||||||
|
} >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
if [ -n "$README_VER" ] && [ "$COMPOSER_VER" != "$README_VER" ]; then
|
||||||
|
echo "::warning::Version mismatch: composer.json=${COMPOSER_VER} vs README.md=${README_VER}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# Gate 5 — Template Integrity
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
templates:
|
||||||
|
name: "Gate 5: Template Integrity"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
needs: code-quality
|
||||||
|
if: github.event_name != 'push' || github.event.inputs.full_suite != 'false'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: "Validate workflow templates"
|
||||||
|
run: |
|
||||||
|
ERRORS=0
|
||||||
|
CHECKED=0
|
||||||
|
|
||||||
|
# Check all YAML workflow templates parse cleanly
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
CHECKED=$((CHECKED + 1))
|
||||||
|
if ! python3 -c "import yaml; yaml.safe_load(open('${file}'))" 2>/dev/null; then
|
||||||
|
echo "::error file=${file}::Invalid YAML"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find templates/workflows/ -name "*.yml" -o -name "*.yaml" 2>/dev/null | tr '\n' '\0')
|
||||||
|
|
||||||
|
# Also check the live workflows
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
CHECKED=$((CHECKED + 1))
|
||||||
|
if ! python3 -c "import yaml; yaml.safe_load(open('${file}'))" 2>/dev/null; then
|
||||||
|
echo "::error file=${file}::Invalid YAML"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find .mokogitea/workflows/ -name "*.yml" -o -name "*.yaml" 2>/dev/null | tr '\n' '\0')
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "### Template Integrity"
|
||||||
|
echo "Validated ${CHECKED} YAML files — ${ERRORS} parse errors"
|
||||||
|
} >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
[ "$ERRORS" -eq 0 ] || exit 1
|
||||||
|
|
||||||
|
- name: "Validate gitignore templates"
|
||||||
|
run: |
|
||||||
|
TEMPLATES=0
|
||||||
|
for GI in templates/configs/gitignore templates/configs/gitignore.dolibarr templates/configs/.gitignore.joomla; do
|
||||||
|
if [ -f "$GI" ]; then
|
||||||
|
TEMPLATES=$((TEMPLATES + 1))
|
||||||
|
# Verify required entries
|
||||||
|
for REQUIRED in ".claude/" "TODO.md" "*.min.css" "*.min.js" "wiki/"; do
|
||||||
|
if ! grep -q "$REQUIRED" "$GI"; then
|
||||||
|
echo "::error file=${GI}::Missing required entry: ${REQUIRED}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "### Gitignore Templates" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Validated ${TEMPLATES} gitignore templates." >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
- name: "Validate PHP validation scripts"
|
||||||
|
run: |
|
||||||
|
ERRORS=0
|
||||||
|
CHECKED=0
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
CHECKED=$((CHECKED + 1))
|
||||||
|
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||||
|
echo "::error file=${file}::Validation script has syntax error"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find validate/ -name "*.php" -print0 2>/dev/null)
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "### Validation Scripts"
|
||||||
|
echo "Checked ${CHECKED} scripts — ${ERRORS} syntax errors"
|
||||||
|
} >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
[ "$ERRORS" -eq 0 ] || { echo "::error::Validation scripts must be error-free"; exit 1; }
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# Summary
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
summary:
|
||||||
|
name: "CI Summary"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [code-quality, tests, self-health, governance, templates]
|
||||||
|
if: always()
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check gate results
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "# moko-platform CI"
|
||||||
|
echo ""
|
||||||
|
echo "| Gate | Job | Status |"
|
||||||
|
echo "|---|---|---|"
|
||||||
|
echo "| 1 | Code Quality | ${{ needs.code-quality.result }} |"
|
||||||
|
echo "| 2 | Unit Tests | ${{ needs.tests.result }} |"
|
||||||
|
echo "| 3 | Self-Health | ${{ needs.self-health.result }} |"
|
||||||
|
echo "| 4 | Governance | ${{ needs.governance.result }} |"
|
||||||
|
echo "| 5 | Templates | ${{ needs.templates.result }} |"
|
||||||
|
echo ""
|
||||||
|
echo "> *The standards engine must pass its own standards.*"
|
||||||
|
} >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# Fail if any required gate failed
|
||||||
|
if [ "${{ needs.code-quality.result }}" = "failure" ] || \
|
||||||
|
[ "${{ needs.tests.result }}" = "failure" ] || \
|
||||||
|
[ "${{ needs.self-health.result }}" = "failure" ] || \
|
||||||
|
[ "${{ needs.governance.result }}" = "failure" ] || \
|
||||||
|
[ "${{ needs.templates.result }}" = "failure" ]; then
|
||||||
|
echo "::error::One or more CI gates failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -21,7 +21,7 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
cleanup:
|
cleanup:
|
||||||
@@ -33,17 +33,17 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
|
||||||
- name: Delete merged branches
|
- name: Delete merged branches
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "=== Merged Branch Cleanup ==="
|
echo "=== Merged Branch Cleanup ==="
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
|
|
||||||
# List branches via API
|
# List branches via API
|
||||||
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
BRANCHES=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||||
"${API}/branches?limit=50" | jq -r '.[].name')
|
"${API}/branches?limit=50" | jq -r '.[].name')
|
||||||
|
|
||||||
DELETED=0
|
DELETED=0
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
# Check if branch is merged into main
|
# Check if branch is merged into main
|
||||||
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
||||||
echo " Deleting merged branch: ${BRANCH}"
|
echo " Deleting merged branch: ${BRANCH}"
|
||||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||||
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
||||||
DELETED=$((DELETED + 1))
|
DELETED=$((DELETED + 1))
|
||||||
fi
|
fi
|
||||||
@@ -66,20 +66,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Clean old workflow runs
|
- name: Clean old workflow runs
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "=== Workflow Run Cleanup ==="
|
echo "=== Workflow Run Cleanup ==="
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
# Get old completed runs
|
# Get old completed runs
|
||||||
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
RUNS=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||||
"${API}/actions/runs?status=completed&limit=50" | \
|
"${API}/actions/runs?status=completed&limit=50" | \
|
||||||
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
||||||
|
|
||||||
DELETED=0
|
DELETED=0
|
||||||
for RUN_ID in $RUNS; do
|
for RUN_ID in $RUNS; do
|
||||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
|
||||||
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
||||||
DELETED=$((DELETED + 1))
|
DELETED=$((DELETED + 1))
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: MokoStandards.Deploy
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||||
|
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
|
||||||
|
# VERSION: 04.07.00
|
||||||
|
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
|
||||||
|
|
||||||
|
name: "Universal: Deploy to Dev (Manual)"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
clear_remote:
|
||||||
|
description: 'Delete all remote files before uploading'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: SFTP Deploy to Dev
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
run: |
|
||||||
|
php -v && composer --version
|
||||||
|
|
||||||
|
- name: Setup MokoStandards tools
|
||||||
|
env:
|
||||||
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
|
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||||
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 --branch main --quiet \
|
||||||
|
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||||
|
/tmp/mokostandards-api 2>/dev/null || true
|
||||||
|
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
||||||
|
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check FTP configuration
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
HOST: ${{ vars.DEV_FTP_HOST }}
|
||||||
|
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
|
||||||
|
PORT: ${{ vars.DEV_FTP_PORT }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
|
||||||
|
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "host=$HOST" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
REMOTE="${PATH_VAR%/}"
|
||||||
|
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
[ -z "$PORT" ] && PORT="22"
|
||||||
|
echo "port=$PORT" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Deploy via SFTP
|
||||||
|
if: steps.check.outputs.skip != 'true'
|
||||||
|
env:
|
||||||
|
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||||
|
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||||
|
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||||
|
run: |
|
||||||
|
SOURCE_DIR="src"
|
||||||
|
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||||
|
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
|
||||||
|
|
||||||
|
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||||
|
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
|
||||||
|
> /tmp/sftp-config.json
|
||||||
|
|
||||||
|
if [ -n "$SFTP_KEY" ]; then
|
||||||
|
echo "$SFTP_KEY" > /tmp/deploy_key
|
||||||
|
chmod 600 /tmp/deploy_key
|
||||||
|
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||||
|
else
|
||||||
|
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
|
||||||
|
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
|
||||||
|
|
||||||
|
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||||
|
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
||||||
|
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
|
||||||
|
else
|
||||||
|
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
|
||||||
|
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
@@ -25,10 +25,6 @@
|
|||||||
name: "Universal: Secret Scanning"
|
name: "Universal: Secret Scanning"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- 'dev/**'
|
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
|
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: moko-platform.Automation
|
# INGROUP: mokocli.Automation
|
||||||
# VERSION: 02.48.24
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
@@ -19,7 +19,7 @@ permissions:
|
|||||||
issues: write
|
issues: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-branch:
|
create-branch:
|
||||||
@@ -28,8 +28,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create branch and comment
|
- name: Create branch and comment
|
||||||
run: |
|
run: |
|
||||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
echo "Created branch: ${BRANCH}"
|
echo "Created branch: ${BRANCH}"
|
||||||
|
|
||||||
# Comment on issue with branch link
|
# Comment on issue with branch link
|
||||||
REPO_URL="${GITEA_URL}/${{ github.repository }}"
|
REPO_URL="${MOKOGITEA_URL}/${{ github.repository }}"
|
||||||
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
|
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
|
||||||
|
|
||||||
curl -sf -X POST \
|
curl -sf -X POST \
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokocli.CI
|
# INGROUP: moko-platform.CI
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||||
# VERSION: 09.23.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: PR gate — branch policy + code validation before merge
|
# BRIEF: PR gate — branch policy + code validation before merge
|
||||||
@@ -96,6 +96,32 @@ jobs:
|
|||||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# ── Secret Scanning ──────────────────────────────────────────────────
|
||||||
|
gitleaks:
|
||||||
|
name: Secret Scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Gitleaks
|
||||||
|
run: |
|
||||||
|
GITLEAKS_VERSION="8.21.2"
|
||||||
|
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
|
||||||
|
| tar -xz -C /usr/local/bin gitleaks
|
||||||
|
|
||||||
|
- name: Scan PR commits for secrets
|
||||||
|
run: |
|
||||||
|
if gitleaks detect --source . --verbose \
|
||||||
|
--log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
|
||||||
|
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "::error::Potential secrets detected in PR commits"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Code Validation ────────────────────────────────────────────────────
|
# ── Code Validation ────────────────────────────────────────────────────
|
||||||
validate:
|
validate:
|
||||||
name: Validate PR
|
name: Validate PR
|
||||||
@@ -470,39 +496,26 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Trigger RC pre-release
|
- name: Trigger RC pre-release
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
BRANCH: ${{ github.head_ref }}
|
BRANCH: ${{ github.head_ref }}
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
run: |
|
run: |
|
||||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
curl -s -X POST "${MOKOGITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${MOKOGITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
# ── Issue Reporter ──────────────────────────────────────────────────────
|
# ── Issue Reporter ──────────────────────────────────────────────────────
|
||||||
report-issues:
|
report-issues:
|
||||||
name: Report Issues
|
name: Report Issues
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [branch-policy, validate]
|
needs: [branch-policy, validate]
|
||||||
if: >-
|
if: >-
|
||||||
always() &&
|
always() &&
|
||||||
needs.validate.result == 'failure'
|
needs.validate.result == 'failure'
|
||||||
|
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
with:
|
||||||
sparse-checkout: automation/ci-issue-reporter.sh
|
gate: "PR Validation"
|
||||||
sparse-checkout-cone-mode: false
|
workflow: "PR Check"
|
||||||
|
severity: error
|
||||||
- name: "File issue for PR validation failure"
|
details: "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
|
||||||
env:
|
secrets: inherit
|
||||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
|
||||||
run: |
|
|
||||||
chmod +x automation/ci-issue-reporter.sh
|
|
||||||
./automation/ci-issue-reporter.sh \
|
|
||||||
--gate "PR Validation" \
|
|
||||||
--workflow "PR Check" \
|
|
||||||
--severity error \
|
|
||||||
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||||
|
|
||||||
@@ -88,8 +88,20 @@ jobs:
|
|||||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||||
|
|
||||||
|
- name: Check platform eligibility (Joomla only)
|
||||||
|
id: eligibility
|
||||||
|
run: |
|
||||||
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
|
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
|
||||||
|
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Resolve metadata and bump version
|
- name: Resolve metadata and bump version
|
||||||
id: meta
|
id: meta
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
# Auto-detect stability from branch name on push, or use input on dispatch
|
# Auto-detect stability from branch name on push, or use input on dispatch
|
||||||
if [ "${{ github.event_name }}" = "push" ]; then
|
if [ "${{ github.event_name }}" = "push" ]; then
|
||||||
@@ -166,20 +178,22 @@ jobs:
|
|||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
id: release
|
id: release
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
php ${MOKO_CLI}/release_create.php \
|
php ${MOKO_CLI}/release_create.php \
|
||||||
--path . --version "$VERSION" --tag "$TAG" \
|
--path . --version "$VERSION" --tag "$TAG" \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||||
|
|
||||||
- name: Update release notes from CHANGELOG.md
|
- name: Update release notes from CHANGELOG.md
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
|
|
||||||
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
|
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
|
||||||
if [ -f "CHANGELOG.md" ]; then
|
if [ -f "CHANGELOG.md" ]; then
|
||||||
@@ -212,10 +226,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Build package and upload
|
- name: Build package and upload
|
||||||
id: package
|
id: package
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
php ${MOKO_CLI}/release_package.php \
|
php ${MOKO_CLI}/release_package.php \
|
||||||
--path . --version "$VERSION" --tag "$TAG" \
|
--path . --version "$VERSION" --tag "$TAG" \
|
||||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||||
@@ -225,9 +240,10 @@ jobs:
|
|||||||
# No need to build, commit, or sync updates.xml from workflows
|
# No need to build, commit, or sync updates.xml from workflows
|
||||||
|
|
||||||
- name: "Delete lesser pre-release channels (cascade)"
|
- name: "Delete lesser pre-release channels (cascade)"
|
||||||
|
if: steps.eligibility.outputs.proceed == 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
php ${MOKO_CLI}/release_cascade.php \
|
php ${MOKO_CLI}/release_cascade.php \
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: MokoPlatform.Universal
|
# INGROUP: mokocli.Universal
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
# PATH: /.mokogitea/workflows/rc-revert.yml
|
# PATH: /.mokogitea/workflows/rc-revert.yml
|
||||||
# VERSION: 09.23.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
|
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
- name: Check actor permission (admin only)
|
- name: Check actor permission (admin only)
|
||||||
id: perm
|
id: perm
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
ACTOR: ${{ github.actor }}
|
ACTOR: ${{ github.actor }}
|
||||||
run: |
|
run: |
|
||||||
@@ -671,42 +671,30 @@ jobs:
|
|||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
# Issue Reporter — file issues for failed gates
|
# Issue Reporter — file issues for failed gates
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
report-issues:
|
report-scripts:
|
||||||
name: "Report Issues"
|
name: "Report: Scripts Governance"
|
||||||
runs-on: ubuntu-latest
|
needs: [access_check, scripts_governance]
|
||||||
needs: [access_check, scripts_governance, repo_health]
|
|
||||||
if: >-
|
if: >-
|
||||||
always() &&
|
always() &&
|
||||||
(needs.scripts_governance.result == 'failure' ||
|
needs.scripts_governance.result == 'failure'
|
||||||
needs.repo_health.result == 'failure')
|
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
with:
|
||||||
sparse-checkout: automation/ci-issue-reporter.sh
|
gate: "Scripts Governance"
|
||||||
sparse-checkout-cone-mode: false
|
workflow: "Repo Health"
|
||||||
|
severity: error
|
||||||
|
details: "Scripts directory policy violations detected. Review required and allowed directories."
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
- name: "File issues for failed gates"
|
report-health:
|
||||||
env:
|
name: "Report: Repository Health"
|
||||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
needs: [access_check, repo_health]
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
if: >-
|
||||||
run: |
|
always() &&
|
||||||
chmod +x automation/ci-issue-reporter.sh
|
needs.repo_health.result == 'failure'
|
||||||
REPORTER="./automation/ci-issue-reporter.sh"
|
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
|
||||||
WF="Repo Health"
|
with:
|
||||||
|
gate: "Repository Health"
|
||||||
report_gate() {
|
workflow: "Repo Health"
|
||||||
local gate="$1" result="$2" details="$3"
|
severity: error
|
||||||
if [ "$result" = "failure" ]; then
|
details: "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
|
||||||
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
|
secrets: inherit
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
report_gate "Scripts Governance" \
|
|
||||||
"${{ needs.scripts_governance.result }}" \
|
|
||||||
"Scripts directory policy violations detected. Review required and allowed directories."
|
|
||||||
|
|
||||||
report_gate "Repository Health" \
|
|
||||||
"${{ needs.repo_health.result }}" \
|
|
||||||
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow.Template
|
||||||
|
# INGROUP: MokoStandards.CI
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
|
||||||
|
# PATH: /.mokogitea/workflows/version-set.yml
|
||||||
|
# VERSION: 01.00.00
|
||||||
|
# BRIEF: Set or reset the extension version across all version-bearing files
|
||||||
|
|
||||||
|
name: "Joomla: Set Version"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version number (e.g. 01.00.00)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
branch:
|
||||||
|
description: "Branch to update (default: current)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
set-version:
|
||||||
|
name: Set Version to ${{ inputs.version }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Validate version format
|
||||||
|
run: |
|
||||||
|
VERSION="${{ inputs.version }}"
|
||||||
|
if ! echo "$VERSION" | grep -qP '^\d{2}\.\d{2}\.\d{2}$'; then
|
||||||
|
echo "::error::Invalid version format '${VERSION}' — expected XX.YY.ZZ (e.g. 01.00.00)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
|
ref: ${{ inputs.branch || github.ref }}
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Update manifest version
|
||||||
|
run: |
|
||||||
|
MANIFEST=""
|
||||||
|
for XML_FILE in $(find . -maxdepth 3 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
|
||||||
|
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
|
||||||
|
MANIFEST="$XML_FILE"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$MANIFEST" ]; then
|
||||||
|
echo "::warning::No Joomla extension manifest found — skipping manifest update"
|
||||||
|
else
|
||||||
|
OLD_VER=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
|
||||||
|
sed -i "s|<version>${OLD_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
|
||||||
|
echo "Manifest: ${OLD_VER} → ${VERSION} (${MANIFEST})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Update README.md version
|
||||||
|
run: |
|
||||||
|
if [ -f "README.md" ]; then
|
||||||
|
if grep -qP '^\s*VERSION:\s*\d' README.md; then
|
||||||
|
sed -i -E "s/(VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" README.md
|
||||||
|
echo "README.md version updated to ${VERSION}"
|
||||||
|
else
|
||||||
|
echo "::warning::No VERSION line found in README.md — skipping"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Update CHANGELOG.md
|
||||||
|
run: |
|
||||||
|
if [ -f "CHANGELOG.md" ]; then
|
||||||
|
DATE=$(date +%Y-%m-%d)
|
||||||
|
# Check if this version already has an entry
|
||||||
|
if grep -q "^\#\# \[${VERSION}\]" CHANGELOG.md; then
|
||||||
|
echo "CHANGELOG.md already has entry for ${VERSION} — skipping"
|
||||||
|
else
|
||||||
|
# Insert new version entry after [Unreleased] or at the top after header
|
||||||
|
if grep -q '^\#\# \[Unreleased\]' CHANGELOG.md; then
|
||||||
|
sed -i "/^\#\# \[Unreleased\]/a\\\\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
|
||||||
|
else
|
||||||
|
sed -i "/^\# Changelog/a\\\\n## [Unreleased]\n\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
|
||||||
|
fi
|
||||||
|
echo "CHANGELOG.md: added entry for ${VERSION}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "::warning::No CHANGELOG.md found — skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Update FILE INFORMATION blocks
|
||||||
|
run: |
|
||||||
|
# Update VERSION in file header blocks (# VERSION: XX.YY.ZZ)
|
||||||
|
find . -maxdepth 1 -type f \( -name "*.yml" -o -name "*.yaml" -o -name "*.php" -o -name "*.md" \) \
|
||||||
|
-not -path "./.git/*" -not -path "./vendor/*" -print0 2>/dev/null | \
|
||||||
|
while IFS= read -r -d '' FILE; do
|
||||||
|
if head -20 "$FILE" | grep -qP '^\s*#?\s*VERSION:\s*\d{2}\.\d{2}\.\d{2}'; then
|
||||||
|
sed -i -E "s/(#?\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" "$FILE"
|
||||||
|
echo "Updated FILE INFORMATION VERSION in ${FILE}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Commit and push
|
||||||
|
run: |
|
||||||
|
git config user.name "Moko Consulting [bot]"
|
||||||
|
git config user.email "hello@mokoconsulting.tech"
|
||||||
|
git add -A
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No version changes detected — nothing to commit"
|
||||||
|
else
|
||||||
|
git commit -m "chore: set version to ${VERSION} [skip bump]
|
||||||
|
|
||||||
|
Authored-by: Moko Consulting"
|
||||||
|
git push
|
||||||
|
echo "### Version Set" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Version updated to \`${VERSION}\` on branch \`${GITHUB_REF_NAME}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: MokoPlatform.Universal
|
# INGROUP: mokocli.Universal
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||||
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
|
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
|
||||||
# VERSION: 01.01.00
|
# VERSION: 01.01.00
|
||||||
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
|
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
name: "Universal: Workflow Sync Trigger"
|
name: "Universal: Workflow Sync Trigger"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [closed]
|
types: [closed]
|
||||||
branches:
|
branches:
|
||||||
@@ -26,8 +27,9 @@ jobs:
|
|||||||
name: Sync workflows to live repos
|
name: Sync workflows to live repos
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: >-
|
if: >-
|
||||||
github.event.pull_request.merged == true &&
|
github.event_name == 'workflow_dispatch' ||
|
||||||
!contains(github.event.pull_request.title, '[skip sync]')
|
(github.event.pull_request.merged == true &&
|
||||||
|
!contains(github.event.pull_request.title, '[skip sync]'))
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Determine platform from repo name
|
- name: Determine platform from repo name
|
||||||
@@ -45,16 +47,22 @@ jobs:
|
|||||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||||
echo "Platform: ${PLATFORM:-all}"
|
echo "Platform: ${PLATFORM:-all}"
|
||||||
|
|
||||||
- name: Clone mokoplatform
|
- name: Clone mokocli
|
||||||
env:
|
env:
|
||||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||||
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokoplatform.git" /tmp/mokoplatform
|
git clone --depth 1 "${MOKOGITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
|
||||||
|
|
||||||
|
- name: Install PHP
|
||||||
|
run: |
|
||||||
|
if ! command -v php &> /dev/null; then
|
||||||
|
apt-get update -qq && apt-get install -y -qq php-cli php-json php-curl > /dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
cd /tmp/mokoplatform
|
cd /tmp/mokocli
|
||||||
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||||
|
|
||||||
- name: Run workflow sync
|
- name: Run workflow sync
|
||||||
@@ -70,4 +78,4 @@ jobs:
|
|||||||
ARGS="${ARGS} --platform-filter ${PLATFORM}"
|
ARGS="${ARGS} --platform-filter ${PLATFORM}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
php /tmp/mokoplatform/cli/workflow_sync.php ${ARGS}
|
php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
|
||||||
|
|||||||
+2
-16
@@ -14,7 +14,7 @@
|
|||||||
INGROUP: MokoSuiteClient.Documentation
|
INGROUP: MokoSuiteClient.Documentation
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
PATH: ./CHANGELOG.md
|
PATH: ./CHANGELOG.md
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
BRIEF: Version history using `Keep a Changelog`
|
BRIEF: Version history using `Keep a Changelog`
|
||||||
-->
|
-->
|
||||||
|
|
||||||
@@ -24,18 +24,7 @@
|
|||||||
### Added
|
### Added
|
||||||
- **Mirror Domains & Staging** — repeatable subform table in DevTools plugin for configuring domain aliases with per-alias offline bypass, robots directive, and labels
|
- **Mirror Domains & Staging** — repeatable subform table in DevTools plugin for configuring domain aliases with per-alias offline bypass, robots directive, and labels
|
||||||
- **Daily Support PIN** — HMAC-SHA256 rotating PIN shown on cpanel module, component dashboard, and HQ site cards
|
- **Daily Support PIN** — HMAC-SHA256 rotating PIN shown on cpanel module, component dashboard, and HQ site cards
|
||||||
- **Support PIN in status bar** — cache/temp module now shows PIN request button instead of domain; click to request, click again to copy
|
- **Domain as support key** — click-to-copy domain in admin status bar
|
||||||
- **Frontend link in status bar** — cache/temp module now has 4 buttons: Site (frontend link), PIN, Cache, Temp
|
|
||||||
- **Help buttons** — all admin views link to Gitea wiki pages via toolbar help button
|
|
||||||
- **Support PIN in heartbeat** — core system plugin includes current PIN in heartbeat payload to HQ
|
|
||||||
- **HQ config sync** — client stores HQ-configured `support_pin_hours` from heartbeat response, PIN TTL now configurable from HQ
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Support PIN UI unified** — `SupportPinHelper::renderBadge()` and `renderScript()` replace 3 separate inline implementations (dashboard, cpanel module, cache module) with click-to-copy on all PIN badges
|
|
||||||
- Admin sidebar menu module now loads component-local language files (fixes untranslated keys for MokoSuiteCross and other components)
|
|
||||||
- Support PIN TTL is now configurable via HQ global options instead of hardcoded 72 hours
|
|
||||||
- Removed MokoSuiteHQ from extension catalog (internal app, not for client sites)
|
|
||||||
- **SupportPinHelper** — shared helper centralises PIN generation across dashboard, cpanel module, cache module, and AJAX controller
|
|
||||||
- **Current IP display** — firewall plugin settings show admin's IP with copy button
|
- **Current IP display** — firewall plugin settings show admin's IP with copy button
|
||||||
- **Heartbeat monitor** — consolidated into core plugin from retired monitor plugin, with diagnostic logging on all bail-out points
|
- **Heartbeat monitor** — consolidated into core plugin from retired monitor plugin, with diagnostic logging on all bail-out points
|
||||||
- **Backup bridge plugin** — discovers MokoSuiteBackup's BackupStatusHelper and sends status in heartbeat payloads
|
- **Backup bridge plugin** — discovers MokoSuiteBackup's BackupStatusHelper and sends status in heartbeat payloads
|
||||||
@@ -64,9 +53,6 @@
|
|||||||
- **Update server migration** — removed migrateUpdateServerUrls, cleanupStaleUpdateSites, fixUpdateRecords, enableUpdateServer calls
|
- **Update server migration** — removed migrateUpdateServerUrls, cleanupStaleUpdateSites, fixUpdateRecords, enableUpdateServer calls
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Health endpoint cron check SQL error — orphan `setQuery(getQuery(true), 0, 5)` produced bare `LIMIT 5`, returning 503 for all health polls
|
|
||||||
- License plugin missing `src/` and `language/` directories causing install failure
|
|
||||||
- PIN generation inconsistency — controller used `floor(now/TTL)` while display used `floor(requestedAt/TTL)`
|
|
||||||
- Plugin files installing to group root instead of element subdirectory (ALTER TABLE DEFAULT '' + empty element cleanup)
|
- Plugin files installing to group root instead of element subdirectory (ALTER TABLE DEFAULT '' + empty element cleanup)
|
||||||
- Orphan extension rows with empty element or display-name-as-element
|
- Orphan extension rows with empty element or display-name-as-element
|
||||||
- Module not publishing (ensureAdminModule direct DB update bypasses checked_out)
|
- Module not publishing (ensureAdminModule direct DB update bypasses checked_out)
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Documentation
|
INGROUP: MokoSuiteClient.Documentation
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
PATH: ./CODE_OF_CONDUCT.md
|
PATH: ./CODE_OF_CONDUCT.md
|
||||||
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
||||||
-->
|
-->
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
DEFGROUP: mokoconsulting-tech.MokoSuiteClientBrand
|
DEFGROUP: mokoconsulting-tech.MokoSuiteClientBrand
|
||||||
INGROUP: MokoStandards.Governance
|
INGROUP: MokoStandards.Governance
|
||||||
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClientBrand
|
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClientBrand
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
PATH: /GOVERNANCE.md
|
PATH: /GOVERNANCE.md
|
||||||
BRIEF: Project governance rules, roles, and decision process for MokoSuiteClientBrand
|
BRIEF: Project governance rules, roles, and decision process for MokoSuiteClientBrand
|
||||||
-->
|
-->
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
INGROUP: MokoSuiteClient.Documentation
|
INGROUP: MokoSuiteClient.Documentation
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
PATH: ./LICENSE.md
|
PATH: ./LICENSE.md
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
BRIEF: Project license (GPL-3.0-or-later)
|
BRIEF: Project license (GPL-3.0-or-later)
|
||||||
-->
|
-->
|
||||||
GNU GENERAL PUBLIC LICENSE
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
|||||||
@@ -1,333 +0,0 @@
|
|||||||
# Makefile for Joomla Extensions
|
|
||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# This is a reference Makefile for building Joomla extensions.
|
|
||||||
# Copy this to your repository root as "Makefile" and customize as needed.
|
|
||||||
#
|
|
||||||
# Supports: Modules, Plugins, Components, Packages, Templates
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# CONFIGURATION - Customize these for your extension
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
# Extension Configuration
|
|
||||||
EXTENSION_NAME := mokosuiteclient
|
|
||||||
EXTENSION_TYPE := package
|
|
||||||
# Options: module, plugin, component, package, template
|
|
||||||
EXTENSION_VERSION := 02.35.00
|
|
||||||
|
|
||||||
# Module Configuration (for modules only)
|
|
||||||
MODULE_TYPE := site
|
|
||||||
# Options: site, admin
|
|
||||||
|
|
||||||
# Plugin Configuration (for plugins only)
|
|
||||||
PLUGIN_GROUP := system
|
|
||||||
# Options: system, content, user, authentication, etc.
|
|
||||||
|
|
||||||
# Directories
|
|
||||||
SRC_DIR := .
|
|
||||||
BUILD_DIR := build
|
|
||||||
DIST_DIR := dist
|
|
||||||
DOCS_DIR := docs
|
|
||||||
|
|
||||||
# Joomla Installation (for local testing - customize paths)
|
|
||||||
JOOMLA_ROOT := /var/www/html/joomla
|
|
||||||
JOOMLA_VERSION := 4
|
|
||||||
|
|
||||||
# Tools
|
|
||||||
PHP := php
|
|
||||||
COMPOSER := composer
|
|
||||||
NPM := npm
|
|
||||||
PHPCS := vendor/bin/phpcs
|
|
||||||
PHPCBF := vendor/bin/phpcbf
|
|
||||||
PHPUNIT := vendor/bin/phpunit
|
|
||||||
ZIP := zip
|
|
||||||
|
|
||||||
# Coding Standards
|
|
||||||
PHPCS_STANDARD := Joomla
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
COLOR_RESET := \033[0m
|
|
||||||
COLOR_GREEN := \033[32m
|
|
||||||
COLOR_YELLOW := \033[33m
|
|
||||||
COLOR_BLUE := \033[34m
|
|
||||||
COLOR_RED := \033[31m
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# TARGETS
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
.PHONY: help
|
|
||||||
help: ## Show this help message
|
|
||||||
@echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)"
|
|
||||||
@echo "$(COLOR_BLUE)║ Joomla Extension Makefile ║$(COLOR_RESET)"
|
|
||||||
@echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)"
|
|
||||||
@echo ""
|
|
||||||
@echo "Extension: $(EXTENSION_NAME) ($(EXTENSION_TYPE)) v$(EXTENSION_VERSION)"
|
|
||||||
@echo ""
|
|
||||||
@echo "$(COLOR_GREEN)Available targets:$(COLOR_RESET)"
|
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(COLOR_BLUE)%-20s$(COLOR_RESET) %s\n", $$1, $$2}'
|
|
||||||
@echo ""
|
|
||||||
@echo "$(COLOR_YELLOW)Quick Start:$(COLOR_RESET)"
|
|
||||||
@echo " 1. make install-deps # Install dependencies"
|
|
||||||
@echo " 2. make build # Build extension package"
|
|
||||||
@echo " 3. make test # Run tests"
|
|
||||||
@echo ""
|
|
||||||
|
|
||||||
.PHONY: install-deps
|
|
||||||
install-deps: ## Install all dependencies (Composer + npm)
|
|
||||||
@echo "$(COLOR_BLUE)Installing dependencies...$(COLOR_RESET)"
|
|
||||||
@if [ -f "composer.json" ]; then \
|
|
||||||
$(COMPOSER) install; \
|
|
||||||
echo "$(COLOR_GREEN)✓ Composer dependencies installed$(COLOR_RESET)"; \
|
|
||||||
fi
|
|
||||||
@if [ -f "package.json" ]; then \
|
|
||||||
$(NPM) install; \
|
|
||||||
echo "$(COLOR_GREEN)✓ npm dependencies installed$(COLOR_RESET)"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: update-deps
|
|
||||||
update-deps: ## Update all dependencies
|
|
||||||
@echo "$(COLOR_BLUE)Updating dependencies...$(COLOR_RESET)"
|
|
||||||
@if [ -f "composer.json" ]; then \
|
|
||||||
$(COMPOSER) update; \
|
|
||||||
echo "$(COLOR_GREEN)✓ Composer dependencies updated$(COLOR_RESET)"; \
|
|
||||||
fi
|
|
||||||
@if [ -f "package.json" ]; then \
|
|
||||||
$(NPM) update; \
|
|
||||||
echo "$(COLOR_GREEN)✓ npm dependencies updated$(COLOR_RESET)"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: lint
|
|
||||||
lint: ## Run PHP linter (syntax check)
|
|
||||||
@echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)"
|
|
||||||
@find . -name "*.php" ! -path "./vendor/*" ! -path "./node_modules/*" ! -path "./$(BUILD_DIR)/*" \
|
|
||||||
-exec $(PHP) -l {} \; | grep -v "No syntax errors" || true
|
|
||||||
@echo "$(COLOR_GREEN)✓ PHP linting complete$(COLOR_RESET)"
|
|
||||||
|
|
||||||
.PHONY: phpcs
|
|
||||||
phpcs: ## Run PHP CodeSniffer (Joomla standards)
|
|
||||||
@echo "$(COLOR_BLUE)Running PHP CodeSniffer...$(COLOR_RESET)"
|
|
||||||
@if [ -f "$(PHPCS)" ]; then \
|
|
||||||
$(PHPCS) --standard=$(PHPCS_STANDARD) --extensions=php --ignore=vendor,node_modules,$(BUILD_DIR) .; \
|
|
||||||
else \
|
|
||||||
echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: make install-deps$(COLOR_RESET)"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: phpcbf
|
|
||||||
phpcbf: ## Fix coding standards automatically
|
|
||||||
@echo "$(COLOR_BLUE)Running PHP Code Beautifier...$(COLOR_RESET)"
|
|
||||||
@if [ -f "$(PHPCBF)" ]; then \
|
|
||||||
$(PHPCBF) --standard=$(PHPCS_STANDARD) --extensions=php --ignore=vendor,node_modules,$(BUILD_DIR) .; \
|
|
||||||
echo "$(COLOR_GREEN)✓ Code formatting applied$(COLOR_RESET)"; \
|
|
||||||
else \
|
|
||||||
echo "$(COLOR_YELLOW)⚠ PHP Code Beautifier not installed. Run: make install-deps$(COLOR_RESET)"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: validate
|
|
||||||
validate: lint phpcs ## Run all validation checks
|
|
||||||
@echo "$(COLOR_GREEN)✓ All validation checks passed$(COLOR_RESET)"
|
|
||||||
|
|
||||||
.PHONY: test
|
|
||||||
test: ## Run PHPUnit tests
|
|
||||||
@echo "$(COLOR_BLUE)Running tests...$(COLOR_RESET)"
|
|
||||||
@if [ -f "$(PHPUNIT)" ] && [ -f "phpunit.xml" ]; then \
|
|
||||||
$(PHPUNIT); \
|
|
||||||
else \
|
|
||||||
echo "$(COLOR_YELLOW)⚠ PHPUnit not configured$(COLOR_RESET)"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: test-coverage
|
|
||||||
test-coverage: ## Run tests with coverage report
|
|
||||||
@echo "$(COLOR_BLUE)Running tests with coverage...$(COLOR_RESET)"
|
|
||||||
@if [ -f "$(PHPUNIT)" ] && [ -f "phpunit.xml" ]; then \
|
|
||||||
$(PHPUNIT) --coverage-html $(BUILD_DIR)/coverage; \
|
|
||||||
echo "$(COLOR_GREEN)✓ Coverage report: $(BUILD_DIR)/coverage/index.html$(COLOR_RESET)"; \
|
|
||||||
else \
|
|
||||||
echo "$(COLOR_YELLOW)⚠ PHPUnit not configured$(COLOR_RESET)"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: clean
|
|
||||||
clean: ## Clean build artifacts
|
|
||||||
@echo "$(COLOR_BLUE)Cleaning build artifacts...$(COLOR_RESET)"
|
|
||||||
@rm -rf $(BUILD_DIR) $(DIST_DIR)
|
|
||||||
@echo "$(COLOR_GREEN)✓ Build artifacts cleaned$(COLOR_RESET)"
|
|
||||||
|
|
||||||
.PHONY: build
|
|
||||||
build: clean validate ## Build extension package
|
|
||||||
@echo "$(COLOR_BLUE)Building Joomla extension package...$(COLOR_RESET)"
|
|
||||||
@mkdir -p $(DIST_DIR) $(BUILD_DIR)
|
|
||||||
|
|
||||||
# Determine package prefix based on extension type
|
|
||||||
@case "$(EXTENSION_TYPE)" in \
|
|
||||||
module) \
|
|
||||||
PACKAGE_PREFIX="mod_$(EXTENSION_NAME)"; \
|
|
||||||
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
|
|
||||||
;; \
|
|
||||||
plugin) \
|
|
||||||
PACKAGE_PREFIX="plg_$(PLUGIN_GROUP)_$(EXTENSION_NAME)"; \
|
|
||||||
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
|
|
||||||
;; \
|
|
||||||
component) \
|
|
||||||
PACKAGE_PREFIX="com_$(EXTENSION_NAME)"; \
|
|
||||||
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
|
|
||||||
;; \
|
|
||||||
package) \
|
|
||||||
PACKAGE_PREFIX="pkg_$(EXTENSION_NAME)"; \
|
|
||||||
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
|
|
||||||
;; \
|
|
||||||
template) \
|
|
||||||
PACKAGE_PREFIX="tpl_$(EXTENSION_NAME)"; \
|
|
||||||
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
|
|
||||||
;; \
|
|
||||||
*) \
|
|
||||||
echo "$(COLOR_RED)✗ Unknown extension type: $(EXTENSION_TYPE)$(COLOR_RESET)"; \
|
|
||||||
exit 1; \
|
|
||||||
;; \
|
|
||||||
esac; \
|
|
||||||
\
|
|
||||||
mkdir -p "$$BUILD_TARGET"; \
|
|
||||||
\
|
|
||||||
echo "Building $$PACKAGE_PREFIX..."; \
|
|
||||||
\
|
|
||||||
rsync -av --progress \
|
|
||||||
--exclude='$(BUILD_DIR)' \
|
|
||||||
--exclude='$(DIST_DIR)' \
|
|
||||||
--exclude='.git*' \
|
|
||||||
--exclude='vendor/' \
|
|
||||||
--exclude='node_modules/' \
|
|
||||||
--exclude='tests/' \
|
|
||||||
--exclude='Makefile' \
|
|
||||||
--exclude='composer.json' \
|
|
||||||
--exclude='composer.lock' \
|
|
||||||
--exclude='package.json' \
|
|
||||||
--exclude='package-lock.json' \
|
|
||||||
--exclude='phpunit.xml' \
|
|
||||||
--exclude='*.md' \
|
|
||||||
--exclude='.editorconfig' \
|
|
||||||
. "$$BUILD_TARGET/"; \
|
|
||||||
\
|
|
||||||
cd $(BUILD_DIR) && $(ZIP) -r "../$(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip" "$${PACKAGE_PREFIX}"; \
|
|
||||||
\
|
|
||||||
echo "$(COLOR_GREEN)✓ Package created: $(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip$(COLOR_RESET)"
|
|
||||||
|
|
||||||
.PHONY: package
|
|
||||||
package: build ## Alias for build
|
|
||||||
@echo "$(COLOR_GREEN)✓ Package ready for distribution$(COLOR_RESET)"
|
|
||||||
|
|
||||||
.PHONY: install-local
|
|
||||||
install-local: build ## Install to local Joomla (upload via admin)
|
|
||||||
@echo "$(COLOR_BLUE)Package ready for installation$(COLOR_RESET)"
|
|
||||||
@case "$(EXTENSION_TYPE)" in \
|
|
||||||
module) PACKAGE="mod_$(EXTENSION_NAME)";; \
|
|
||||||
plugin) PACKAGE="plg_$(PLUGIN_GROUP)_$(EXTENSION_NAME)";; \
|
|
||||||
component) PACKAGE="com_$(EXTENSION_NAME)";; \
|
|
||||||
package) PACKAGE="pkg_$(EXTENSION_NAME)";; \
|
|
||||||
template) PACKAGE="tpl_$(EXTENSION_NAME)";; \
|
|
||||||
esac; \
|
|
||||||
echo "$(COLOR_YELLOW)Upload $(DIST_DIR)/$${PACKAGE}-$(EXTENSION_VERSION).zip via Joomla Administrator$(COLOR_RESET)"; \
|
|
||||||
echo "Admin URL: $(JOOMLA_ROOT) → Extensions → Install"
|
|
||||||
|
|
||||||
.PHONY: dev-install
|
|
||||||
dev-install: ## Create symlink for development (Joomla 4+)
|
|
||||||
@echo "$(COLOR_BLUE)Creating development symlink...$(COLOR_RESET)"
|
|
||||||
@if [ ! -d "$(JOOMLA_ROOT)" ]; then \
|
|
||||||
echo "$(COLOR_RED)✗ Joomla root not found at $(JOOMLA_ROOT)$(COLOR_RESET)"; \
|
|
||||||
echo "Update JOOMLA_ROOT in Makefile"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
@case "$(EXTENSION_TYPE)" in \
|
|
||||||
module) \
|
|
||||||
if [ "$(MODULE_TYPE)" = "admin" ]; then \
|
|
||||||
TARGET="$(JOOMLA_ROOT)/administrator/modules/mod_$(EXTENSION_NAME)"; \
|
|
||||||
else \
|
|
||||||
TARGET="$(JOOMLA_ROOT)/modules/mod_$(EXTENSION_NAME)"; \
|
|
||||||
fi; \
|
|
||||||
;; \
|
|
||||||
plugin) \
|
|
||||||
TARGET="$(JOOMLA_ROOT)/plugins/$(PLUGIN_GROUP)/$(EXTENSION_NAME)"; \
|
|
||||||
;; \
|
|
||||||
component) \
|
|
||||||
echo "$(COLOR_YELLOW)⚠ Components require complex symlink setup$(COLOR_RESET)"; \
|
|
||||||
echo "Manual setup recommended for component development"; \
|
|
||||||
exit 1; \
|
|
||||||
;; \
|
|
||||||
*) \
|
|
||||||
echo "$(COLOR_RED)✗ dev-install not supported for $(EXTENSION_TYPE)$(COLOR_RESET)"; \
|
|
||||||
exit 1; \
|
|
||||||
;; \
|
|
||||||
esac; \
|
|
||||||
\
|
|
||||||
rm -rf "$$TARGET"; \
|
|
||||||
ln -s "$(PWD)" "$$TARGET"; \
|
|
||||||
echo "$(COLOR_GREEN)✓ Development symlink created at $$TARGET$(COLOR_RESET)"
|
|
||||||
|
|
||||||
.PHONY: watch
|
|
||||||
watch: ## Watch for changes and rebuild
|
|
||||||
@echo "$(COLOR_BLUE)Watching for changes...$(COLOR_RESET)"
|
|
||||||
@echo "$(COLOR_YELLOW)Press Ctrl+C to stop$(COLOR_RESET)"
|
|
||||||
@while true; do \
|
|
||||||
inotifywait -r -e modify,create,delete --exclude '($(BUILD_DIR)|$(DIST_DIR)|vendor|node_modules)' . 2>/dev/null || \
|
|
||||||
(echo "$(COLOR_YELLOW)⚠ inotifywait not installed. Install: apt-get install inotify-tools$(COLOR_RESET)" && sleep 5); \
|
|
||||||
make build; \
|
|
||||||
done
|
|
||||||
|
|
||||||
.PHONY: version
|
|
||||||
version: ## Display version information
|
|
||||||
@echo "$(COLOR_BLUE)Extension Information:$(COLOR_RESET)"
|
|
||||||
@echo " Name: $(EXTENSION_NAME)"
|
|
||||||
@echo " Type: $(EXTENSION_TYPE)"
|
|
||||||
@echo " Version: $(EXTENSION_VERSION)"
|
|
||||||
@if [ "$(EXTENSION_TYPE)" = "module" ]; then \
|
|
||||||
echo " Module: $(MODULE_TYPE)"; \
|
|
||||||
fi
|
|
||||||
@if [ "$(EXTENSION_TYPE)" = "plugin" ]; then \
|
|
||||||
echo " Group: $(PLUGIN_GROUP)"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: docs
|
|
||||||
docs: ## Generate documentation
|
|
||||||
@echo "$(COLOR_BLUE)Generating documentation...$(COLOR_RESET)"
|
|
||||||
@mkdir -p $(DOCS_DIR)
|
|
||||||
@echo "$(COLOR_YELLOW)⚠ Documentation generation not configured$(COLOR_RESET)"
|
|
||||||
@echo "Consider adding phpDocumentor or similar"
|
|
||||||
|
|
||||||
.PHONY: release
|
|
||||||
release: validate test build ## Create a release (validate + test + build)
|
|
||||||
@echo "$(COLOR_GREEN)✓ Release package ready$(COLOR_RESET)"
|
|
||||||
@echo ""
|
|
||||||
@echo "$(COLOR_BLUE)Release Checklist:$(COLOR_RESET)"
|
|
||||||
@echo " [ ] Update CHANGELOG.md"
|
|
||||||
@echo " [ ] Update version in XML manifest"
|
|
||||||
@echo " [ ] Test installation in clean Joomla"
|
|
||||||
@echo " [ ] Tag release in git: git tag v$(EXTENSION_VERSION)"
|
|
||||||
@echo " [ ] Push tags: git push --tags"
|
|
||||||
@echo " [ ] Create GitHub release"
|
|
||||||
@echo ""
|
|
||||||
@case "$(EXTENSION_TYPE)" in \
|
|
||||||
module) PACKAGE="mod_$(EXTENSION_NAME)";; \
|
|
||||||
plugin) PACKAGE="plg_$(PLUGIN_GROUP)_$(EXTENSION_NAME)";; \
|
|
||||||
component) PACKAGE="com_$(EXTENSION_NAME)";; \
|
|
||||||
package) PACKAGE="pkg_$(EXTENSION_NAME)";; \
|
|
||||||
template) PACKAGE="tpl_$(EXTENSION_NAME)";; \
|
|
||||||
esac; \
|
|
||||||
echo "$(COLOR_GREEN)Package: $(DIST_DIR)/$${PACKAGE}-$(EXTENSION_VERSION).zip$(COLOR_RESET)"
|
|
||||||
|
|
||||||
.PHONY: security-check
|
|
||||||
security-check: ## Run security checks on dependencies
|
|
||||||
@echo "$(COLOR_BLUE)Running security checks...$(COLOR_RESET)"
|
|
||||||
@if [ -f "composer.json" ]; then \
|
|
||||||
$(COMPOSER) audit || echo "$(COLOR_YELLOW)⚠ Vulnerabilities found$(COLOR_RESET)"; \
|
|
||||||
fi
|
|
||||||
@if [ -f "package.json" ]; then \
|
|
||||||
$(NPM) audit || echo "$(COLOR_YELLOW)⚠ Vulnerabilities found$(COLOR_RESET)"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: all
|
|
||||||
all: install-deps validate test build ## Run complete build pipeline
|
|
||||||
@echo "$(COLOR_GREEN)✓ Complete build pipeline finished$(COLOR_RESET)"
|
|
||||||
|
|
||||||
# Default target
|
|
||||||
.DEFAULT_GOAL := help
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient
|
INGROUP: MokoSuiteClient
|
||||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
PATH: /README.md
|
PATH: /README.md
|
||||||
BRIEF: MokoSuiteClient platform plugin for Joomla
|
BRIEF: MokoSuiteClient platform plugin for Joomla
|
||||||
-->
|
-->
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
|
|||||||
INGROUP: [PROJECT_NAME].Documentation
|
INGROUP: [PROJECT_NAME].Documentation
|
||||||
REPO: [REPOSITORY_URL]
|
REPO: [REPOSITORY_URL]
|
||||||
PATH: /SECURITY.md
|
PATH: /SECURITY.md
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
BRIEF: Security vulnerability reporting and handling policy
|
BRIEF: Security vulnerability reporting and handling policy
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ============================================================================
|
|
||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Automation.CI
|
|
||||||
# INGROUP: moko-platform.Automation
|
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
|
||||||
# PATH: /automation/ci-issue-reporter.sh
|
|
||||||
# VERSION: 09.23.00
|
|
||||||
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
|
|
||||||
# Deduplicates by searching open issues with the "ci-auto" label
|
|
||||||
# whose title matches the gate. If a matching issue exists, a comment
|
|
||||||
# is appended instead of opening a duplicate.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ── Defaults ────────────────────────────────────────────────────────────────
|
|
||||||
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
|
||||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
|
||||||
REPO="${GITHUB_REPOSITORY:-}"
|
|
||||||
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
|
|
||||||
LABEL_NAME="ci-auto"
|
|
||||||
LABEL_COLOR="#e11d48"
|
|
||||||
|
|
||||||
GATE=""
|
|
||||||
DETAILS=""
|
|
||||||
SEVERITY="error"
|
|
||||||
WORKFLOW=""
|
|
||||||
|
|
||||||
# ── Parse arguments ─────────────────────────────────────────────────────────
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
|
|
||||||
|
|
||||||
Required:
|
|
||||||
--gate CI gate name (e.g. "Code Quality", "Self-Health")
|
|
||||||
--details Human-readable failure description
|
|
||||||
|
|
||||||
Optional:
|
|
||||||
--severity "error" (default) or "warning"
|
|
||||||
--workflow Workflow name for the issue title
|
|
||||||
--repo owner/repo (default: \$GITHUB_REPOSITORY)
|
|
||||||
--run-url URL to the CI run (auto-detected from env)
|
|
||||||
--token Gitea API token (default: \$GITEA_TOKEN)
|
|
||||||
--url Gitea base URL (default: \$GITEA_URL)
|
|
||||||
EOF
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--gate) GATE="$2"; shift 2 ;;
|
|
||||||
--details) DETAILS="$2"; shift 2 ;;
|
|
||||||
--severity) SEVERITY="$2"; shift 2 ;;
|
|
||||||
--workflow) WORKFLOW="$2"; shift 2 ;;
|
|
||||||
--repo) REPO="$2"; shift 2 ;;
|
|
||||||
--run-url) RUN_URL="$2"; shift 2 ;;
|
|
||||||
--token) GITEA_TOKEN="$2"; shift 2 ;;
|
|
||||||
--url) GITEA_URL="$2"; shift 2 ;;
|
|
||||||
-h|--help) usage ;;
|
|
||||||
*) echo "Unknown option: $1"; usage ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
|
|
||||||
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
|
|
||||||
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
|
|
||||||
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
|
|
||||||
|
|
||||||
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
|
||||||
|
|
||||||
# ── Build title ─────────────────────────────────────────────────────────────
|
|
||||||
if [[ -n "$WORKFLOW" ]]; then
|
|
||||||
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
|
|
||||||
else
|
|
||||||
TITLE="[CI] ${GATE} failed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Ensure label exists ─────────────────────────────────────────────────────
|
|
||||||
ensure_label() {
|
|
||||||
local exists
|
|
||||||
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
"${API}/labels" 2>/dev/null || echo "000")
|
|
||||||
|
|
||||||
if [[ "$exists" == "200" ]]; then
|
|
||||||
# Check if label already exists
|
|
||||||
local found
|
|
||||||
found=$(curl -sf \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
"${API}/labels" 2>/dev/null \
|
|
||||||
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
|
|
||||||
|
|
||||||
if [[ -z "$found" ]]; then
|
|
||||||
curl -sf -X POST \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API}/labels" \
|
|
||||||
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
|
|
||||||
> /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Search for existing open issue ──────────────────────────────────────────
|
|
||||||
find_existing_issue() {
|
|
||||||
# URL-encode the gate name for the query
|
|
||||||
local query
|
|
||||||
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
|
|
||||||
|
|
||||||
local response
|
|
||||||
response=$(curl -sf \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
|
|
||||||
2>/dev/null || echo "[]")
|
|
||||||
|
|
||||||
# Extract the first matching issue number
|
|
||||||
echo "$response" \
|
|
||||||
| grep -oP '"number":\s*\K[0-9]+' \
|
|
||||||
| head -1
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Build issue body ────────────────────────────────────────────────────────
|
|
||||||
build_body() {
|
|
||||||
local severity_badge
|
|
||||||
if [[ "$SEVERITY" == "error" ]]; then
|
|
||||||
severity_badge="**Severity:** Error"
|
|
||||||
else
|
|
||||||
severity_badge="**Severity:** Warning"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat <<BODY
|
|
||||||
## CI Gate Failure: ${GATE}
|
|
||||||
|
|
||||||
${severity_badge}
|
|
||||||
**Workflow:** ${WORKFLOW:-unknown}
|
|
||||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
|
||||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
|
||||||
**Run:** [View CI run](${RUN_URL})
|
|
||||||
|
|
||||||
### Details
|
|
||||||
|
|
||||||
${DETAILS}
|
|
||||||
|
|
||||||
### Resolution
|
|
||||||
|
|
||||||
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
|
|
||||||
|
|
||||||
---
|
|
||||||
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
|
|
||||||
BODY
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Build comment body (for existing issues) ────────────────────────────────
|
|
||||||
build_comment() {
|
|
||||||
cat <<COMMENT
|
|
||||||
### CI failure recurrence
|
|
||||||
|
|
||||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
|
||||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
|
||||||
**Run:** [View CI run](${RUN_URL})
|
|
||||||
|
|
||||||
${DETAILS}
|
|
||||||
COMMENT
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Main ────────────────────────────────────────────────────────────────────
|
|
||||||
ensure_label
|
|
||||||
|
|
||||||
EXISTING=$(find_existing_issue)
|
|
||||||
|
|
||||||
if [[ -n "$EXISTING" ]]; then
|
|
||||||
# Append comment to existing issue
|
|
||||||
COMMENT_BODY=$(build_comment)
|
|
||||||
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
|
|
||||||
import sys, json
|
|
||||||
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
|
|
||||||
|
|
||||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API}/issues/${EXISTING}/comments" \
|
|
||||||
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
|
|
||||||
|
|
||||||
if [[ "$HTTP" == "201" ]]; then
|
|
||||||
echo "Commented on existing issue #${EXISTING}"
|
|
||||||
else
|
|
||||||
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# Create new issue
|
|
||||||
ISSUE_BODY=$(build_body)
|
|
||||||
ISSUE_JSON=$(python3 -c "
|
|
||||||
import sys, json
|
|
||||||
body = sys.stdin.read()
|
|
||||||
print(json.dumps({
|
|
||||||
'title': sys.argv[1],
|
|
||||||
'body': body,
|
|
||||||
'labels': []
|
|
||||||
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
|
|
||||||
|
|
||||||
# Create the issue
|
|
||||||
RESPONSE=$(curl -sf -X POST \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API}/issues" \
|
|
||||||
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
|
|
||||||
|
|
||||||
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
|
|
||||||
|
|
||||||
if [[ -n "$ISSUE_NUM" ]]; then
|
|
||||||
# Apply label (separate call — more reliable across Gitea versions)
|
|
||||||
LABEL_ID=$(curl -sf \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
"${API}/labels" 2>/dev/null \
|
|
||||||
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
|
|
||||||
| head -1 || true)
|
|
||||||
|
|
||||||
if [[ -n "$LABEL_ID" ]]; then
|
|
||||||
curl -sf -X POST \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API}/issues/${ISSUE_NUM}/labels" \
|
|
||||||
-d "{\"labels\":[${LABEL_ID}]}" \
|
|
||||||
> /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
|
|
||||||
else
|
|
||||||
echo "WARNING: Failed to create issue"
|
|
||||||
echo "Response: ${RESPONSE}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
@@ -11,13 +11,13 @@
|
|||||||
INGROUP: MokoSuiteClient.Build
|
INGROUP: MokoSuiteClient.Build
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
FILE: build-guide.md
|
FILE: build-guide.md
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
PATH: /docs/guides/
|
PATH: /docs/guides/
|
||||||
BRIEF: Build and packaging guide for the MokoSuiteClient system plugin
|
BRIEF: Build and packaging guide for the MokoSuiteClient system plugin
|
||||||
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
|
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Build Guide (VERSION: 02.48.24)
|
# MokoSuiteClient Build Guide (VERSION: 02.47.48)
|
||||||
|
|
||||||
## 1. Purpose
|
## 1. Purpose
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Guides
|
INGROUP: MokoSuiteClient.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
PATH: /docs/guides/configuration-guide.md
|
PATH: /docs/guides/configuration-guide.md
|
||||||
BRIEF: Configuration guide for the MokoSuiteClient system plugin
|
BRIEF: Configuration guide for the MokoSuiteClient system plugin
|
||||||
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
|
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Configuration Guide (VERSION: 02.48.24)
|
# MokoSuiteClient Configuration Guide (VERSION: 02.47.48)
|
||||||
|
|
||||||
## 1. Objective
|
## 1. Objective
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Guides
|
INGROUP: MokoSuiteClient.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
PATH: /docs/guides/installation-guide.md
|
PATH: /docs/guides/installation-guide.md
|
||||||
BRIEF: Installation guide for the MokoSuiteClient system plugin
|
BRIEF: Installation guide for the MokoSuiteClient system plugin
|
||||||
NOTE: First document in the guide set
|
NOTE: First document in the guide set
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Installation Guide (VERSION: 02.48.24)
|
# MokoSuiteClient Installation Guide (VERSION: 02.47.48)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Guides
|
INGROUP: MokoSuiteClient.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
PATH: /docs/guides/operations-guide.md
|
PATH: /docs/guides/operations-guide.md
|
||||||
BRIEF: Operational guide for administering and managing the MokoSuiteClient system plugin
|
BRIEF: Operational guide for administering and managing the MokoSuiteClient system plugin
|
||||||
NOTE: Defines lifecycle, responsibilities, and operational behaviors
|
NOTE: Defines lifecycle, responsibilities, and operational behaviors
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Operations Guide (VERSION: 02.48.24)
|
# MokoSuiteClient Operations Guide (VERSION: 02.47.48)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Guides
|
INGROUP: MokoSuiteClient.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
PATH: /docs/guides/rollback-and-recovery-guide.md
|
PATH: /docs/guides/rollback-and-recovery-guide.md
|
||||||
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
|
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
|
||||||
NOTE: Completes the core guide set for Suite plugin governance
|
NOTE: Completes the core guide set for Suite plugin governance
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.48.24)
|
# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.47.48)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Guides
|
INGROUP: MokoSuiteClient.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
PATH: /docs/guides/testing-guide.md
|
PATH: /docs/guides/testing-guide.md
|
||||||
BRIEF: Testing guide for MokoSuiteClient v02.01.08
|
BRIEF: Testing guide for MokoSuiteClient v02.01.08
|
||||||
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
|
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Testing Guide (VERSION: 02.48.24)
|
# MokoSuiteClient Testing Guide (VERSION: 02.47.48)
|
||||||
|
|
||||||
## 1. Prerequisites
|
## 1. Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Guides
|
INGROUP: MokoSuiteClient.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
PATH: /docs/guides/troubleshooting-guide.md
|
PATH: /docs/guides/troubleshooting-guide.md
|
||||||
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuiteClient plugin
|
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuiteClient plugin
|
||||||
NOTE: Designed for administrators and Suite operations teams
|
NOTE: Designed for administrators and Suite operations teams
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Troubleshooting Guide (VERSION: 02.48.24)
|
# MokoSuiteClient Troubleshooting Guide (VERSION: 02.47.48)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Guides
|
INGROUP: MokoSuiteClient.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
PATH: /docs/guides/upgrade-and-versioning-guide.md
|
PATH: /docs/guides/upgrade-and-versioning-guide.md
|
||||||
BRIEF: Guide for updating, versioning, and maintaining the MokoSuiteClient plugin
|
BRIEF: Guide for updating, versioning, and maintaining the MokoSuiteClient plugin
|
||||||
NOTE: Defines release flow, version rules, and upgrade validation
|
NOTE: Defines release flow, version rules, and upgrade validation
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.48.24)
|
# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.47.48)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuiteClient.Documentation
|
INGROUP: MokoSuiteClient.Documentation
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
PATH: /docs/index.md
|
PATH: /docs/index.md
|
||||||
BRIEF: Master index of all documentation for the MokoSuiteClient plugin
|
BRIEF: Master index of all documentation for the MokoSuiteClient plugin
|
||||||
NOTE: Automatically maintained index for all guide canvases
|
NOTE: Automatically maintained index for all guide canvases
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Documentation Index (VERSION: 02.48.24)
|
# MokoSuiteClient Documentation Index (VERSION: 02.47.48)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,12 @@
|
|||||||
INGROUP: MokoSuiteClient
|
INGROUP: MokoSuiteClient
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
PATH: /docs/plugin-basic.md
|
PATH: /docs/plugin-basic.md
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
BRIEF: Baseline documentation for the MokoSuiteClient system plugin
|
BRIEF: Baseline documentation for the MokoSuiteClient system plugin
|
||||||
NOTE: Foundational reference for internal and external stakeholders
|
NOTE: Foundational reference for internal and external stakeholders
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuiteClient Plugin Overview (VERSION: 02.48.24)
|
# MokoSuiteClient Plugin Overview (VERSION: 02.47.48)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ DEFGROUP: MokoSuiteClient.Documentation
|
|||||||
INGROUP: MokoStandards.Templates
|
INGROUP: MokoStandards.Templates
|
||||||
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClient
|
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClient
|
||||||
PATH: /docs/update-server.md
|
PATH: /docs/update-server.md
|
||||||
VERSION: 02.48.24
|
VERSION: 02.47.48
|
||||||
BRIEF: How this extension's Joomla update server file (update.xml) is managed
|
BRIEF: How this extension's Joomla update server file (update.xml) is managed
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,15 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<access component="com_mokosuiteclient">
|
<access component="com_mokosuiteclient">
|
||||||
<section name="component">
|
<section name="component">
|
||||||
<!-- Core Joomla ACL -->
|
|
||||||
<action name="core.admin" title="JACTION_ADMIN" description="JACTION_ADMIN_COMPONENT_DESC" />
|
<action name="core.admin" title="JACTION_ADMIN" description="JACTION_ADMIN_COMPONENT_DESC" />
|
||||||
<action name="core.manage" title="JACTION_MANAGE" description="JACTION_MANAGE_COMPONENT_DESC" />
|
<action name="core.manage" title="JACTION_MANAGE" description="JACTION_MANAGE_COMPONENT_DESC" />
|
||||||
|
|
||||||
<!-- Dashboard & UI -->
|
|
||||||
<action name="mokosuiteclient.dashboard" title="COM_MOKOSUITECLIENT_ACL_DASHBOARD" description="COM_MOKOSUITECLIENT_ACL_DASHBOARD_DESC" />
|
<action name="mokosuiteclient.dashboard" title="COM_MOKOSUITECLIENT_ACL_DASHBOARD" description="COM_MOKOSUITECLIENT_ACL_DASHBOARD_DESC" />
|
||||||
|
<action name="mokosuiteclient.extensions" title="COM_MOKOSUITECLIENT_ACL_EXTENSIONS" description="COM_MOKOSUITECLIENT_ACL_EXTENSIONS_DESC" />
|
||||||
|
<action name="mokosuiteclient.htaccess" title="COM_MOKOSUITECLIENT_ACL_HTACCESS" description="COM_MOKOSUITECLIENT_ACL_HTACCESS_DESC" />
|
||||||
|
<action name="mokosuiteclient.tickets" title="COM_MOKOSUITECLIENT_ACL_TICKETS" description="COM_MOKOSUITECLIENT_ACL_TICKETS_DESC" />
|
||||||
|
<action name="mokosuiteclient.tickets.create" title="COM_MOKOSUITECLIENT_ACL_TICKETS_CREATE" description="COM_MOKOSUITECLIENT_ACL_TICKETS_CREATE_DESC" />
|
||||||
|
<action name="mokosuiteclient.tickets.assign" title="COM_MOKOSUITECLIENT_ACL_TICKETS_ASSIGN" description="COM_MOKOSUITECLIENT_ACL_TICKETS_ASSIGN_DESC" />
|
||||||
<action name="mokosuiteclient.plugins.toggle" title="COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE" description="COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE_DESC" />
|
<action name="mokosuiteclient.plugins.toggle" title="COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE" description="COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE_DESC" />
|
||||||
<action name="mokosuiteclient.cache" title="COM_MOKOSUITECLIENT_ACL_CACHE" description="COM_MOKOSUITECLIENT_ACL_CACHE_DESC" />
|
<action name="mokosuiteclient.cache" title="COM_MOKOSUITECLIENT_ACL_CACHE" description="COM_MOKOSUITECLIENT_ACL_CACHE_DESC" />
|
||||||
|
|
||||||
<!-- Server Config -->
|
|
||||||
<action name="mokosuiteclient.htaccess" title="COM_MOKOSUITECLIENT_ACL_HTACCESS" description="COM_MOKOSUITECLIENT_ACL_HTACCESS_DESC" />
|
|
||||||
|
|
||||||
<!-- Security -->
|
|
||||||
<action name="mokosuiteclient.security.waflog" title="COM_MOKOSUITECLIENT_ACL_WAFLOG" description="COM_MOKOSUITECLIENT_ACL_WAFLOG_DESC" />
|
|
||||||
<action name="mokosuiteclient.security.impersonate" title="COM_MOKOSUITECLIENT_ACL_IMPERSONATE" description="COM_MOKOSUITECLIENT_ACL_IMPERSONATE_DESC" />
|
|
||||||
|
|
||||||
<!-- Content Tools -->
|
|
||||||
<action name="mokosuiteclient.snippets.manage" title="COM_MOKOSUITECLIENT_ACL_SNIPPETS" description="COM_MOKOSUITECLIENT_ACL_SNIPPETS_DESC" />
|
|
||||||
<action name="mokosuiteclient.templates.manage" title="COM_MOKOSUITECLIENT_ACL_TEMPLATES" description="COM_MOKOSUITECLIENT_ACL_TEMPLATES_DESC" />
|
|
||||||
<action name="mokosuiteclient.replacements.manage" title="COM_MOKOSUITECLIENT_ACL_REPLACEMENTS" description="COM_MOKOSUITECLIENT_ACL_REPLACEMENTS_DESC" />
|
|
||||||
<action name="mokosuiteclient.conditions.manage" title="COM_MOKOSUITECLIENT_ACL_CONDITIONS" description="COM_MOKOSUITECLIENT_ACL_CONDITIONS_DESC" />
|
|
||||||
|
|
||||||
<!-- Extensions & Catalog -->
|
|
||||||
<action name="mokosuiteclient.extensions" title="COM_MOKOSUITECLIENT_ACL_EXTENSIONS" description="COM_MOKOSUITECLIENT_ACL_EXTENSIONS_DESC" />
|
|
||||||
</section>
|
</section>
|
||||||
</access>
|
</access>
|
||||||
|
|||||||
@@ -1,210 +1,122 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!--
|
<!--
|
||||||
MokoSuite Extension Catalog
|
Extension catalog for MokoSuiteClient Extension Manager.
|
||||||
Each entry points to the extension's updates.xml on the main branch.
|
Each entry points to the extension's own updates.xml. The installer
|
||||||
The installer resolves the latest version and download URL at runtime,
|
resolves the latest version and download URL at runtime, respecting
|
||||||
respecting the site's configured update channel (stable/dev) from
|
the site's configured update channel (dev/stable).
|
||||||
Joomla's com_installer params.
|
|
||||||
|
To add an extension: copy an <extension> block and fill in the fields.
|
||||||
-->
|
-->
|
||||||
<catalog>
|
<catalog>
|
||||||
<!-- ═══════════════════════════════════════════════════════════════════
|
|
||||||
Platform (Layer 0)
|
|
||||||
═══════════════════════════════════════════════════════════════════ -->
|
|
||||||
<extension>
|
<extension>
|
||||||
<name>MokoSuiteClient</name>
|
<name>MokoSuiteClient</name>
|
||||||
<element>pkg_mokosuiteclient</element>
|
<element>pkg_mokosuiteclient</element>
|
||||||
<type>package</type>
|
<type>package</type>
|
||||||
<description>Admin dashboard, security firewall, tenant restrictions, health monitoring, content tools, and REST API.</description>
|
<description>Admin dashboard, security firewall, tenant restrictions, health monitoring, and REST API.</description>
|
||||||
<icon>icon-shield-alt</icon>
|
<icon>icon-shield-alt</icon>
|
||||||
<category>Platform</category>
|
<category>Platform</category>
|
||||||
|
<article>https://mokoconsulting.tech/support/products/mokosuiteclient-platform</article>
|
||||||
<protected>true</protected>
|
<protected>true</protected>
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/raw/branch/main/updates.xml</updateserver>
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/raw/branch/dev/updates.xml</updateserver>
|
||||||
</extension>
|
</extension>
|
||||||
<extension>
|
<extension>
|
||||||
<name>MokoSuiteBackup</name>
|
<name>MokoSuiteClientHQ</name>
|
||||||
<element>pkg_mokosuitebackup</element>
|
<element>pkg_mokosuiteclienthq</element>
|
||||||
<type>package</type>
|
<type>package</type>
|
||||||
<description>Full-site backup and restore for Joomla — database, files, and configuration.</description>
|
<description>Centralized control panel for managing all MokoSuiteClient client installations.</description>
|
||||||
<icon>icon-archive</icon>
|
<icon>icon-tachometer-alt</icon>
|
||||||
<category>Platform</category>
|
<category>Platform</category>
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/raw/branch/main/updates.xml</updateserver>
|
<article>https://mokoconsulting.tech/support/products/mokosuiteclient-base</article>
|
||||||
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClientHQ/raw/branch/dev/updates.xml</updateserver>
|
||||||
</extension>
|
</extension>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════════════
|
|
||||||
Business Suite (Layers 1-4)
|
|
||||||
═══════════════════════════════════════════════════════════════════ -->
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuiteCRM</name>
|
|
||||||
<element>pkg_mokosuitecrm</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Layer 1 — Contacts, deals pipeline, activities, e-signature, email integration, helpdesk.</description>
|
|
||||||
<icon>icon-address-book</icon>
|
|
||||||
<category>Business</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuiteERP</name>
|
|
||||||
<element>pkg_mokosuiteerp</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Layer 2 — Products, orders, invoicing, inventory, warehouses, accounting, payments.</description>
|
|
||||||
<icon>icon-briefcase</icon>
|
|
||||||
<category>Business</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteERP/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuiteShop</name>
|
|
||||||
<element>pkg_mokosuiteshop</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Layer 3 — Product catalog, shopping cart, checkout, coupons. Requires MokoSuiteERP.</description>
|
|
||||||
<icon>icon-shopping-cart</icon>
|
|
||||||
<category>Business</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteShop/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuitePOS</name>
|
|
||||||
<element>pkg_mokosuitepos</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Layer 3 — Touch-screen POS, multi-terminal, cash register, receipt printing.</description>
|
|
||||||
<icon>icon-calculator</icon>
|
|
||||||
<category>Business</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuitePOS/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuiteMRP</name>
|
|
||||||
<element>pkg_mokosuitemrp</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Layer 3 — BOM, manufacturing orders, workstation management, production scheduling.</description>
|
|
||||||
<icon>icon-cog</icon>
|
|
||||||
<category>Business</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteMRP/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuiteHRM</name>
|
|
||||||
<element>pkg_mokosuitehrm</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Layer 3 — Human Resource Management: employees, leave, expenses, payroll, recruiting.</description>
|
|
||||||
<icon>icon-users</icon>
|
|
||||||
<category>Business</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteHRM/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuiteRestaurant</name>
|
|
||||||
<element>pkg_mokosuiterestaurant</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Layer 4 — Floor plan, table management, kitchen display, split bills, online ordering.</description>
|
|
||||||
<icon>icon-utensils</icon>
|
|
||||||
<category>Industry</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteRestaurant/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuiteChild</name>
|
|
||||||
<element>pkg_mokosuitechild</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Layer 2 — Child Care Management: enrollment, attendance, billing, parent portal.</description>
|
|
||||||
<icon>icon-child</icon>
|
|
||||||
<category>Industry</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteChild/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuiteNPO</name>
|
|
||||||
<element>pkg_mokosuitenpo</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Nonprofit management: donors, donations, campaigns, grants, volunteers, events.</description>
|
|
||||||
<icon>icon-heart</icon>
|
|
||||||
<category>Industry</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteNPO/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuiteField</name>
|
|
||||||
<element>pkg_mokosuitefield</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Field Service — dispatch, work orders, scheduling, mobile tech, plumbing/HVAC.</description>
|
|
||||||
<icon>icon-wrench</icon>
|
|
||||||
<category>Industry</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteField/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuiteCreate</name>
|
|
||||||
<element>pkg_mokosuitecreate</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Layer 2 — Creative Agency: projects, tasks, timesheets, client proofing.</description>
|
|
||||||
<icon>icon-paint-brush</icon>
|
|
||||||
<category>Industry</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCreate/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════════════
|
|
||||||
Content & Community
|
|
||||||
═══════════════════════════════════════════════════════════════════ -->
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuiteForms</name>
|
|
||||||
<element>pkg_mokosuiteforms</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Form builder — custom forms, submissions, notifications, and data exports.</description>
|
|
||||||
<icon>icon-list-alt</icon>
|
|
||||||
<category>Content</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteForms/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuiteCommunity</name>
|
|
||||||
<element>pkg_mokosuitecommunity</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Community profiles, connections, and activity streams for Joomla.</description>
|
|
||||||
<icon>icon-users</icon>
|
|
||||||
<category>Content</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCommunity/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuiteCross</name>
|
|
||||||
<element>pkg_mokosuitecross</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Cross-posting Joomla content to social media, email marketing, and chat platforms.</description>
|
|
||||||
<icon>icon-share-alt</icon>
|
|
||||||
<category>Content</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCross/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuiteOpenGraph</name>
|
|
||||||
<element>pkg_mokosuiteopengraph</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Open Graph, Twitter Card, JSON-LD structured data, and social sharing meta tags.</description>
|
|
||||||
<icon>icon-share-alt</icon>
|
|
||||||
<category>SEO</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
<extension>
|
|
||||||
<name>MokoSuiteStoreLocator</name>
|
|
||||||
<element>pkg_mokosuitestorelocator</element>
|
|
||||||
<type>package</type>
|
|
||||||
<description>Interactive map, location search, and admin management for store locations.</description>
|
|
||||||
<icon>icon-map-marker-alt</icon>
|
|
||||||
<category>Content</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════════════
|
|
||||||
Standalone Extensions (MokoJoom)
|
|
||||||
═══════════════════════════════════════════════════════════════════ -->
|
|
||||||
<extension>
|
|
||||||
<name>MokoJoomHero</name>
|
|
||||||
<element>mod_mokojoomhero</element>
|
|
||||||
<type>module</type>
|
|
||||||
<description>Hero module — image slideshow, video backgrounds, solid color/gradient, parallax.</description>
|
|
||||||
<icon>icon-image</icon>
|
|
||||||
<category>Modules</category>
|
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/main/updates.xml</updateserver>
|
|
||||||
</extension>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════════════
|
|
||||||
Templates
|
|
||||||
═══════════════════════════════════════════════════════════════════ -->
|
|
||||||
<extension>
|
<extension>
|
||||||
<name>MokoOnyx</name>
|
<name>MokoOnyx</name>
|
||||||
<element>mokoonyx</element>
|
<element>mokoonyx</element>
|
||||||
<type>template</type>
|
<type>template</type>
|
||||||
<description>Modern Joomla site template with dark mode, custom layouts, and MokoSuite integration.</description>
|
<description>Modern Joomla site template with dark mode, custom layouts, and MokoSuiteClient integration.</description>
|
||||||
<icon>icon-paint-brush</icon>
|
<icon>icon-paint-brush</icon>
|
||||||
<category>Templates</category>
|
<category>Templates</category>
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/updates.xml</updateserver>
|
<article>https://mokoconsulting.tech/support/products/mokoonyx-template</article>
|
||||||
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/dev/updates.xml</updateserver>
|
||||||
|
</extension>
|
||||||
|
<extension>
|
||||||
|
<name>MokoJoomOpenGraph</name>
|
||||||
|
<element>pkg_mokoog</element>
|
||||||
|
<type>package</type>
|
||||||
|
<description>Open Graph, Twitter Card, and social sharing meta tags for articles, categories, and pages.</description>
|
||||||
|
<icon>icon-share-alt</icon>
|
||||||
|
<category>SEO</category>
|
||||||
|
<article>https://mokoconsulting.tech/support/products/mokojoomopengraph</article>
|
||||||
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml</updateserver>
|
||||||
|
</extension>
|
||||||
|
<extension>
|
||||||
|
<name>MokoSuiteClientBackup</name>
|
||||||
|
<element>pkg_mokojoombackup</element>
|
||||||
|
<type>package</type>
|
||||||
|
<description>Full-site backup and restore for Joomla — database, files, and configuration.</description>
|
||||||
|
<icon>icon-archive</icon>
|
||||||
|
<category>Tools</category>
|
||||||
|
<article>https://mokoconsulting.tech/support/products/mokosuiteclientbackup</article>
|
||||||
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClientBackup/raw/branch/dev/updates.xml</updateserver>
|
||||||
|
</extension>
|
||||||
|
<extension>
|
||||||
|
<name>MokoJoomHero</name>
|
||||||
|
<element>mod_mokojoomhero</element>
|
||||||
|
<type>module</type>
|
||||||
|
<description>Random hero image module from a configurable folder.</description>
|
||||||
|
<icon>icon-image</icon>
|
||||||
|
<category>Modules</category>
|
||||||
|
<article>https://mokoconsulting.tech/support/products/mokojoomhero</article>
|
||||||
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/dev/updates.xml</updateserver>
|
||||||
|
</extension>
|
||||||
|
<extension>
|
||||||
|
<name>MokoJoomCommunity</name>
|
||||||
|
<element>pkg_mokojoomcommunity</element>
|
||||||
|
<type>package</type>
|
||||||
|
<description>Community Builder integration package with custom fields and user management.</description>
|
||||||
|
<icon>icon-users</icon>
|
||||||
|
<category>Community</category>
|
||||||
|
<article>https://mokoconsulting.tech/support/products/mokojoomcommunity</article>
|
||||||
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCommunity/raw/branch/dev/updates.xml</updateserver>
|
||||||
|
</extension>
|
||||||
|
<extension>
|
||||||
|
<name>MokoJoomCross</name>
|
||||||
|
<element>plg_system_mokojoomcross</element>
|
||||||
|
<type>plugin</type>
|
||||||
|
<description>Cross-extension integration plugin for Joomla component interoperability.</description>
|
||||||
|
<icon>icon-link</icon>
|
||||||
|
<category>Plugins</category>
|
||||||
|
<article>https://mokoconsulting.tech/support/products/mokojoomcross</article>
|
||||||
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCross/raw/branch/dev/updates.xml</updateserver>
|
||||||
|
</extension>
|
||||||
|
<extension>
|
||||||
|
<name>MokoJoomStoreLocator</name>
|
||||||
|
<element>mod_mokojoomstorelocator</element>
|
||||||
|
<type>module</type>
|
||||||
|
<description>Store locator module with Google Maps integration and search.</description>
|
||||||
|
<icon>icon-map-marker-alt</icon>
|
||||||
|
<category>Modules</category>
|
||||||
|
<article>https://mokoconsulting.tech/support/products/mokojoomstorelocator</article>
|
||||||
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomStoreLocator/raw/branch/dev/updates.xml</updateserver>
|
||||||
|
</extension>
|
||||||
|
<extension>
|
||||||
|
<name>DPCalendar API</name>
|
||||||
|
<element>mokodpcalendarapi</element>
|
||||||
|
<type>plugin</type>
|
||||||
|
<description>Web Services plugin exposing DPCalendar events and calendars via REST API.</description>
|
||||||
|
<icon>icon-calendar</icon>
|
||||||
|
<category>Plugins</category>
|
||||||
|
<article>https://mokoconsulting.tech/support/products/mokodpcalendarapi</article>
|
||||||
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoDPCalendarAPI/raw/branch/dev/updates.xml</updateserver>
|
||||||
|
</extension>
|
||||||
|
<extension>
|
||||||
|
<name>Gallery Calendar</name>
|
||||||
|
<element>mokogallerycalendar</element>
|
||||||
|
<type>plugin</type>
|
||||||
|
<description>JoomGallery and DPCalendar integration — link galleries to events.</description>
|
||||||
|
<icon>icon-images</icon>
|
||||||
|
<category>Plugins</category>
|
||||||
|
<article>https://mokoconsulting.tech/support/products/mokogallerycalendar</article>
|
||||||
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoGalleryCalendar/raw/branch/dev/updates.xml</updateserver>
|
||||||
</extension>
|
</extension>
|
||||||
</catalog>
|
</catalog>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<config>
|
<config>
|
||||||
<fieldset name="general" label="General" description="General component settings.">
|
<fieldset name="general" label="General" description="General component settings.">
|
||||||
<field name="brand_name" type="text" default="MokoSuite"
|
<field name="brand_name" type="text" default="MokoSuiteClient"
|
||||||
label="Brand Name"
|
label="Brand Name"
|
||||||
description="Displayed in the admin sidebar, dashboard, and emails."
|
description="Displayed in the admin sidebar, dashboard, and emails."
|
||||||
hint="MokoSuite" />
|
hint="MokoSuiteClient" />
|
||||||
<field name="support_email" type="email" default=""
|
<field name="support_email" type="email" default=""
|
||||||
label="Support Email"
|
label="Support Email"
|
||||||
description="Reply-to address for outbound notification emails."
|
description="Reply-to address for outbound notification emails."
|
||||||
hint="support@example.com" />
|
hint="support@example.com" />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="notifications" label="Notifications" description="Email and push notification settings.">
|
<fieldset name="notifications" label="Email Notifications" description="Configure email recipients for ticket and security notifications.">
|
||||||
<field name="admin_emails" type="text" default=""
|
<field name="admin_emails" type="text" default=""
|
||||||
label="Admin Email Addresses"
|
label="Admin Email Addresses"
|
||||||
description="Comma-separated email addresses to receive all notifications."
|
description="Comma-separated email addresses to receive all notifications."
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<field name="spacer_ntfy" type="spacer" label="Push Notifications (ntfy)" />
|
<field name="spacer_ntfy" type="spacer" label="Push Notifications (ntfy)" />
|
||||||
<field name="ntfy_enabled" type="radio" default="0"
|
<field name="ntfy_enabled" type="radio" default="0"
|
||||||
label="Enable ntfy Push"
|
label="Enable ntfy Push"
|
||||||
description="Send push notifications via ntfy for security and system events."
|
description="Send push notifications via ntfy for ticket and security events."
|
||||||
class="btn-group btn-group-yesno">
|
class="btn-group btn-group-yesno">
|
||||||
<option value="1">JYES</option>
|
<option value="1">JYES</option>
|
||||||
<option value="0">JNO</option>
|
<option value="0">JNO</option>
|
||||||
@@ -40,13 +40,13 @@
|
|||||||
label="ntfy Server URL"
|
label="ntfy Server URL"
|
||||||
description="Full URL to your ntfy server."
|
description="Full URL to your ntfy server."
|
||||||
showon="ntfy_enabled:1" />
|
showon="ntfy_enabled:1" />
|
||||||
<field name="ntfy_topic" type="text" default="mokosuite-alerts"
|
<field name="ntfy_topic" type="text" default="mokosuiteclient-tickets"
|
||||||
label="Alert Topic"
|
label="Ticket Topic"
|
||||||
description="ntfy topic name for general alert notifications."
|
description="ntfy topic name for helpdesk ticket notifications."
|
||||||
showon="ntfy_enabled:1" />
|
showon="ntfy_enabled:1" />
|
||||||
<field name="ntfy_security_topic" type="text" default="mokosuite-security"
|
<field name="ntfy_security_topic" type="text" default="mokosuiteclient-security"
|
||||||
label="Security Topic"
|
label="Security Topic"
|
||||||
description="ntfy topic name for security alerts. Falls back to alert topic if empty."
|
description="ntfy topic name for security alert notifications. Falls back to ticket topic if empty."
|
||||||
showon="ntfy_enabled:1" />
|
showon="ntfy_enabled:1" />
|
||||||
<field name="ntfy_token" type="password" default=""
|
<field name="ntfy_token" type="password" default=""
|
||||||
label="ntfy Auth Token"
|
label="ntfy Auth Token"
|
||||||
@@ -54,42 +54,59 @@
|
|||||||
showon="ntfy_enabled:1" />
|
showon="ntfy_enabled:1" />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="content_tools" label="Content Tools" description="Settings for content tag engines and replacements.">
|
<fieldset name="helpdesk" label="Helpdesk Settings" description="Default helpdesk behavior.">
|
||||||
<field name="spacer_snippets" type="spacer" label="Snippets" />
|
<field name="default_category" type="sql" default=""
|
||||||
<field name="snippets_default_category" type="text" default=""
|
label="Default Ticket Category"
|
||||||
label="Default Snippet Category"
|
description="Category assigned to tickets without a selection."
|
||||||
description="Category assigned to new snippets if none selected." />
|
query="SELECT id AS value, title AS text FROM #__mokosuiteclient_ticket_categories WHERE published = 1 ORDER BY ordering" />
|
||||||
|
<field name="autoclose_days" type="number" default="7"
|
||||||
<field name="spacer_templates" type="spacer" label="Content Templates" />
|
label="Auto-Close After (days)"
|
||||||
<field name="templates_default_category" type="text" default=""
|
description="Resolved tickets are auto-closed after this many days. 0 = disabled." />
|
||||||
label="Default Template Category"
|
<field name="kb_search_enabled" type="radio" default="1"
|
||||||
description="Category assigned to new content templates if none selected." />
|
label="KB Search on Ticket Forms"
|
||||||
|
description="Show knowledge base search before ticket submission."
|
||||||
<field name="spacer_replacements" type="spacer" label="Replacements" />
|
class="btn-group btn-group-yesno">
|
||||||
<field name="replacements_max_rules" type="number" default="100"
|
<option value="1">JYES</option>
|
||||||
label="Max Active Rules"
|
<option value="0">JNO</option>
|
||||||
description="Maximum number of replacement rules processed per page load. 0 = unlimited." />
|
</field>
|
||||||
|
<field name="satisfaction_enabled" type="radio" default="1"
|
||||||
|
label="Satisfaction Ratings"
|
||||||
|
description="Show rating prompt on resolved tickets."
|
||||||
|
class="btn-group btn-group-yesno">
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field name="max_attachment_size" type="number" default="10"
|
||||||
|
label="Max Attachment Size (MB)"
|
||||||
|
description="Maximum upload size per file in megabytes." />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="impersonation" label="User Impersonation" description="Skeleton Key — log into the frontend as another user for support.">
|
<fieldset name="email_to_ticket" label="Email-to-Ticket (IMAP)" description="Create tickets from incoming emails via IMAP polling.">
|
||||||
<field name="skeleton_key_control_groups" type="usergrouplist" default="8"
|
<field name="imap_host" type="text" default=""
|
||||||
label="Groups Allowed to Impersonate"
|
label="IMAP Server"
|
||||||
description="User groups that can log in as another user."
|
description="IMAP hostname (e.g. imap.gmail.com)"
|
||||||
multiple="true"
|
hint="imap.gmail.com" />
|
||||||
layout="joomla.form.field.list-fancy-select" />
|
<field name="imap_port" type="number" default="993"
|
||||||
<field name="skeleton_key_target_groups" type="usergrouplist" default="2"
|
label="Port"
|
||||||
label="Groups That Can Be Impersonated"
|
description="IMAP port (993 for SSL, 143 for plain)" />
|
||||||
description="User groups whose accounts can be accessed via impersonation."
|
<field name="imap_ssl" type="radio" default="1"
|
||||||
multiple="true"
|
label="Use SSL"
|
||||||
layout="joomla.form.field.list-fancy-select" />
|
class="btn-group btn-group-yesno">
|
||||||
<field name="skeleton_key_blocked_groups" type="usergrouplist" default="7,8"
|
<option value="1">JYES</option>
|
||||||
label="Groups That Cannot Be Impersonated"
|
<option value="0">JNO</option>
|
||||||
description="User groups protected from impersonation (overrides target groups)."
|
</field>
|
||||||
multiple="true"
|
<field name="imap_user" type="text" default=""
|
||||||
layout="joomla.form.field.list-fancy-select" />
|
label="Username"
|
||||||
<field name="skeleton_key_cookie_lifetime" type="number" default="10"
|
description="IMAP login username or email address." />
|
||||||
label="Cookie Lifetime (seconds)"
|
<field name="imap_password" type="password" default=""
|
||||||
description="How long the impersonation cookie remains valid. Short values are more secure." />
|
label="Password"
|
||||||
|
description="IMAP password or app-specific password." />
|
||||||
|
<field name="imap_folder" type="text" default="INBOX"
|
||||||
|
label="Inbox Folder"
|
||||||
|
description="IMAP folder to poll for new messages." />
|
||||||
|
<field name="imap_processed_folder" type="text" default="INBOX.Processed"
|
||||||
|
label="Processed Folder"
|
||||||
|
description="Move processed emails to this folder. Leave empty to just mark as read." />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="permissions" label="COM_MOKOSUITECLIENT_ACL_TITLE"
|
<fieldset name="permissions" label="COM_MOKOSUITECLIENT_ACL_TITLE"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
; MokoSuite Admin Dashboard - Language Strings
|
; MokoSuiteClient Admin Dashboard - Language Strings
|
||||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
; License: GPL-3.0-or-later
|
; License: GPL-3.0-or-later
|
||||||
|
|
||||||
COM_MOKOSUITECLIENT_DASHBOARD_TITLE="MokoSuite Control Panel"
|
COM_MOKOSUITECLIENT_DASHBOARD_TITLE="MokoSuiteClient Control Panel"
|
||||||
|
|
||||||
; Joomla core fallback keys
|
; Joomla core fallback keys (in case language files are corrupt/missing)
|
||||||
COM_ACTIONLOGS_DISABLED="User Action Logging is disabled. Please enable the "Action Log - Joomla" plugin."
|
COM_ACTIONLOGS_DISABLED="User Action Logging is disabled. Please enable the "Action Log - Joomla" plugin."
|
||||||
COM_MOKOSUITECLIENT_SITE="Site"
|
COM_MOKOSUITECLIENT_SITE="Site"
|
||||||
COM_MOKOSUITECLIENT_DATABASE="Database"
|
COM_MOKOSUITECLIENT_DATABASE="Database"
|
||||||
@@ -23,29 +23,22 @@ COM_MOKOSUITECLIENT_EXTENSIONS_TITLE="Moko Extensions"
|
|||||||
COM_MOKOSUITECLIENT_EXTENSIONS_INFO="Install Moko Consulting Joomla packages from the official release server. Updates are handled through Joomla's native System > Update mechanism — each package registers its own update server."
|
COM_MOKOSUITECLIENT_EXTENSIONS_INFO="Install Moko Consulting Joomla packages from the official release server. Updates are handled through Joomla's native System > Update mechanism — each package registers its own update server."
|
||||||
COM_MOKOSUITECLIENT_EXTENSIONS_LINK="Moko Extensions"
|
COM_MOKOSUITECLIENT_EXTENSIONS_LINK="Moko Extensions"
|
||||||
COM_MOKOSUITECLIENT_HTACCESS_TITLE=".htaccess Maker"
|
COM_MOKOSUITECLIENT_HTACCESS_TITLE=".htaccess Maker"
|
||||||
|
COM_MOKOSUITECLIENT_TICKETS_TITLE="Helpdesk"
|
||||||
|
|
||||||
; ACL
|
; ACL
|
||||||
COM_MOKOSUITECLIENT_ACL_TITLE="Permissions"
|
|
||||||
COM_MOKOSUITECLIENT_ACL_DESC="Manage access permissions for MokoSuite component features."
|
|
||||||
COM_MOKOSUITECLIENT_ACL_DASHBOARD="View Dashboard"
|
COM_MOKOSUITECLIENT_ACL_DASHBOARD="View Dashboard"
|
||||||
COM_MOKOSUITECLIENT_ACL_DASHBOARD_DESC="Allow viewing the MokoSuite control panel dashboard."
|
COM_MOKOSUITECLIENT_ACL_DASHBOARD_DESC="Allow viewing the MokoSuiteClient control panel dashboard."
|
||||||
COM_MOKOSUITECLIENT_ACL_EXTENSIONS="Manage Extensions"
|
COM_MOKOSUITECLIENT_ACL_EXTENSIONS="Manage Extensions"
|
||||||
COM_MOKOSUITECLIENT_ACL_EXTENSIONS_DESC="Allow installing and uninstalling Moko extensions."
|
COM_MOKOSUITECLIENT_ACL_EXTENSIONS_DESC="Allow installing and uninstalling Moko extensions."
|
||||||
|
COM_MOKOSUITECLIENT_ACL_HTACCESS="Manage .htaccess"
|
||||||
|
COM_MOKOSUITECLIENT_ACL_HTACCESS_DESC="Allow editing and saving the .htaccess configuration."
|
||||||
|
COM_MOKOSUITECLIENT_ACL_TICKETS="View Tickets"
|
||||||
|
COM_MOKOSUITECLIENT_ACL_TICKETS_DESC="Allow viewing helpdesk tickets."
|
||||||
|
COM_MOKOSUITECLIENT_ACL_TICKETS_CREATE="Create Tickets"
|
||||||
|
COM_MOKOSUITECLIENT_ACL_TICKETS_CREATE_DESC="Allow creating new helpdesk tickets."
|
||||||
|
COM_MOKOSUITECLIENT_ACL_TICKETS_ASSIGN="Assign Tickets"
|
||||||
|
COM_MOKOSUITECLIENT_ACL_TICKETS_ASSIGN_DESC="Allow assigning tickets to other users."
|
||||||
COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE="Toggle Plugins"
|
COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE="Toggle Plugins"
|
||||||
COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE_DESC="Allow enabling and disabling MokoSuite feature plugins."
|
COM_MOKOSUITECLIENT_ACL_PLUGINS_TOGGLE_DESC="Allow enabling and disabling MokoSuiteClient feature plugins."
|
||||||
COM_MOKOSUITECLIENT_ACL_CACHE="Clear Cache"
|
COM_MOKOSUITECLIENT_ACL_CACHE="Clear Cache"
|
||||||
COM_MOKOSUITECLIENT_ACL_CACHE_DESC="Allow clearing the Joomla cache from the dashboard."
|
COM_MOKOSUITECLIENT_ACL_CACHE_DESC="Allow clearing the Joomla cache from the dashboard."
|
||||||
COM_MOKOSUITECLIENT_ACL_HTACCESS="Manage .htaccess"
|
|
||||||
COM_MOKOSUITECLIENT_ACL_HTACCESS_DESC="Allow editing and saving the .htaccess and nginx configuration."
|
|
||||||
COM_MOKOSUITECLIENT_ACL_WAFLOG="View WAF Log"
|
|
||||||
COM_MOKOSUITECLIENT_ACL_WAFLOG_DESC="Allow viewing the Web Application Firewall activity log."
|
|
||||||
COM_MOKOSUITECLIENT_ACL_IMPERSONATE="Impersonate Users"
|
|
||||||
COM_MOKOSUITECLIENT_ACL_IMPERSONATE_DESC="Allow logging into the frontend as another user for support purposes."
|
|
||||||
COM_MOKOSUITECLIENT_ACL_SNIPPETS="Manage Snippets"
|
|
||||||
COM_MOKOSUITECLIENT_ACL_SNIPPETS_DESC="Allow creating, editing, and deleting reusable content snippets."
|
|
||||||
COM_MOKOSUITECLIENT_ACL_TEMPLATES="Manage Content Templates"
|
|
||||||
COM_MOKOSUITECLIENT_ACL_TEMPLATES_DESC="Allow creating, editing, and deleting article content templates."
|
|
||||||
COM_MOKOSUITECLIENT_ACL_REPLACEMENTS="Manage Replacements"
|
|
||||||
COM_MOKOSUITECLIENT_ACL_REPLACEMENTS_DESC="Allow creating, editing, and deleting text replacement rules."
|
|
||||||
COM_MOKOSUITECLIENT_ACL_CONDITIONS="Manage Conditions"
|
|
||||||
COM_MOKOSUITECLIENT_ACL_CONDITIONS_DESC="Allow creating, editing, and deleting display condition sets for modules and content."
|
|
||||||
|
|||||||
@@ -227,126 +227,3 @@ CREATE TABLE IF NOT EXISTS `#__mokosuite_license_cache` (
|
|||||||
PRIMARY KEY (`dlid_hash`),
|
PRIMARY KEY (`dlid_hash`),
|
||||||
KEY `idx_checked` (`checked_at`)
|
KEY `idx_checked` (`checked_at`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- Conditions Engine — rule-based display conditions
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_conditions` (
|
|
||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
`alias` VARCHAR(100) NOT NULL DEFAULT '',
|
|
||||||
`name` VARCHAR(100) NOT NULL DEFAULT '',
|
|
||||||
`description` TEXT NOT NULL,
|
|
||||||
`category` VARCHAR(50) NOT NULL DEFAULT '',
|
|
||||||
`color` VARCHAR(8) DEFAULT NULL,
|
|
||||||
`match_all` TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
`published` TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
`hash` VARCHAR(32) NOT NULL DEFAULT '',
|
|
||||||
`checked_out` INT UNSIGNED DEFAULT NULL,
|
|
||||||
`checked_out_time` DATETIME DEFAULT NULL,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_published` (`published`),
|
|
||||||
KEY `idx_alias` (`alias`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_conditions_groups` (
|
|
||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
`condition_id` INT UNSIGNED NOT NULL,
|
|
||||||
`match_all` TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
`ordering` INT UNSIGNED NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_condition` (`condition_id`),
|
|
||||||
KEY `idx_ordering` (`condition_id`, `ordering`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_conditions_rules` (
|
|
||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
`group_id` INT UNSIGNED NOT NULL,
|
|
||||||
`type` VARCHAR(50) NOT NULL DEFAULT '',
|
|
||||||
`exclude` TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
`params` TEXT NOT NULL,
|
|
||||||
`ordering` INT UNSIGNED NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_group` (`group_id`),
|
|
||||||
KEY `idx_type` (`type`),
|
|
||||||
KEY `idx_ordering` (`group_id`, `ordering`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_conditions_map` (
|
|
||||||
`condition_id` INT UNSIGNED NOT NULL,
|
|
||||||
`extension` VARCHAR(50) NOT NULL DEFAULT '',
|
|
||||||
`item_id` INT UNSIGNED NOT NULL DEFAULT 0,
|
|
||||||
UNIQUE KEY `idx_unique` (`condition_id`, `item_id`, `extension`),
|
|
||||||
KEY `idx_ext_item` (`extension`, `item_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- Snippets — reusable text/HTML blocks insertable via {snippet}
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_snippets` (
|
|
||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
`alias` VARCHAR(100) NOT NULL DEFAULT '',
|
|
||||||
`name` VARCHAR(100) NOT NULL DEFAULT '',
|
|
||||||
`description` TEXT NOT NULL,
|
|
||||||
`category` VARCHAR(50) NOT NULL DEFAULT '',
|
|
||||||
`color` VARCHAR(8) DEFAULT NULL,
|
|
||||||
`content` MEDIUMTEXT NOT NULL,
|
|
||||||
`params` TEXT NOT NULL,
|
|
||||||
`published` TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
`ordering` INT NOT NULL DEFAULT 0,
|
|
||||||
`checked_out` INT UNSIGNED DEFAULT NULL,
|
|
||||||
`checked_out_time` DATETIME DEFAULT NULL,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_alias` (`alias`),
|
|
||||||
KEY `idx_published` (`published`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- ReReplacer — backend-managed string/regex replacement rules
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_replacements` (
|
|
||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
`name` VARCHAR(100) NOT NULL DEFAULT '',
|
|
||||||
`search` TEXT NOT NULL,
|
|
||||||
`replace_value` TEXT NOT NULL,
|
|
||||||
`area` VARCHAR(20) NOT NULL DEFAULT 'both',
|
|
||||||
`regex` TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
`casesensitive` TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
`category` VARCHAR(50) NOT NULL DEFAULT '',
|
|
||||||
`published` TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
`description` TEXT NOT NULL,
|
|
||||||
`enable_in_admin` TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
`color` VARCHAR(8) DEFAULT NULL,
|
|
||||||
`ordering` INT NOT NULL DEFAULT 0,
|
|
||||||
`checked_out` INT UNSIGNED DEFAULT NULL,
|
|
||||||
`checked_out_time` DATETIME DEFAULT NULL,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_published` (`published`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Content Templates
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_content_templates` (
|
|
||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
`alias` VARCHAR(100) NOT NULL DEFAULT '',
|
|
||||||
`name` VARCHAR(255) NOT NULL DEFAULT '',
|
|
||||||
`description` TEXT NOT NULL,
|
|
||||||
`category` VARCHAR(50) NOT NULL DEFAULT '',
|
|
||||||
`color` VARCHAR(8) DEFAULT NULL,
|
|
||||||
`template_data` MEDIUMTEXT NOT NULL,
|
|
||||||
`joomla_category_id` INT NOT NULL DEFAULT 0,
|
|
||||||
`access` INT UNSIGNED NOT NULL DEFAULT 1,
|
|
||||||
`published` TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
`ordering` INT NOT NULL DEFAULT 0,
|
|
||||||
`checked_out` INT UNSIGNED DEFAULT NULL,
|
|
||||||
`checked_out_time` DATETIME DEFAULT NULL,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_published` (`published`),
|
|
||||||
KEY `idx_alias` (`alias`),
|
|
||||||
KEY `idx_category` (`joomla_category_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
/* 02.47.95 — no schema changes */
|
|
||||||
@@ -27,15 +27,16 @@ class DisplayController extends BaseController
|
|||||||
'dashboard' => 'mokosuiteclient.dashboard',
|
'dashboard' => 'mokosuiteclient.dashboard',
|
||||||
'extensions' => 'mokosuiteclient.extensions',
|
'extensions' => 'mokosuiteclient.extensions',
|
||||||
'htaccess' => 'mokosuiteclient.htaccess',
|
'htaccess' => 'mokosuiteclient.htaccess',
|
||||||
|
'tickets' => 'mokosuiteclient.tickets',
|
||||||
|
'ticket' => 'mokosuiteclient.tickets',
|
||||||
'privacy' => 'core.admin',
|
'privacy' => 'core.admin',
|
||||||
'waflog' => 'mokosuiteclient.security.waflog',
|
'waflog' => 'core.admin',
|
||||||
|
'categories' => 'mokosuiteclient.tickets',
|
||||||
|
'canned' => 'mokosuiteclient.tickets',
|
||||||
'automation' => 'core.admin',
|
'automation' => 'core.admin',
|
||||||
'database' => 'core.admin',
|
'database' => 'core.admin',
|
||||||
'cleanup' => 'mokosuiteclient.cache',
|
'cleanup' => 'mokosuiteclient.cache',
|
||||||
'snippets' => 'mokosuiteclient.snippets.manage',
|
'ticketsettings' => 'core.admin',
|
||||||
'templates' => 'mokosuiteclient.templates.manage',
|
|
||||||
'replacements' => 'mokosuiteclient.replacements.manage',
|
|
||||||
'conditions' => 'mokosuiteclient.conditions.manage',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public function display($cachable = false, $urlparams = [])
|
public function display($cachable = false, $urlparams = [])
|
||||||
@@ -141,22 +142,6 @@ class DisplayController extends BaseController
|
|||||||
$domain = parse_url($siteUrl, PHP_URL_HOST) ?: '';
|
$domain = parse_url($siteUrl, PHP_URL_HOST) ?: '';
|
||||||
$timestamp = time();
|
$timestamp = time();
|
||||||
|
|
||||||
// Discover all MokoSuite ecosystem packages for HQ
|
|
||||||
$mokoPackages = [];
|
|
||||||
try {
|
|
||||||
$pkgDb = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
||||||
$pkgQuery = $pkgDb->getQuery(true)
|
|
||||||
->select([$pkgDb->quoteName('element'), $pkgDb->quoteName('manifest_cache')])
|
|
||||||
->from($pkgDb->quoteName('#__extensions'))
|
|
||||||
->where('(' . $pkgDb->quoteName('element') . ' LIKE ' . $pkgDb->quote('pkg_mokosuite%')
|
|
||||||
. ' OR ' . $pkgDb->quoteName('element') . ' LIKE ' . $pkgDb->quote('pkg_mokojoom%') . ')');
|
|
||||||
$pkgDb->setQuery($pkgQuery);
|
|
||||||
foreach ($pkgDb->loadObjectList() ?: [] as $pkg) {
|
|
||||||
$m = json_decode($pkg->manifest_cache ?? '{}');
|
|
||||||
$mokoPackages[$pkg->element] = $m->version ?? '';
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {}
|
|
||||||
|
|
||||||
$payload = json_encode([
|
$payload = json_encode([
|
||||||
'token' => $healthToken,
|
'token' => $healthToken,
|
||||||
'domain' => $domain,
|
'domain' => $domain,
|
||||||
@@ -165,7 +150,6 @@ class DisplayController extends BaseController
|
|||||||
'joomla_version' => (new \Joomla\CMS\Version())->getShortVersion(),
|
'joomla_version' => (new \Joomla\CMS\Version())->getShortVersion(),
|
||||||
'php_version' => PHP_VERSION,
|
'php_version' => PHP_VERSION,
|
||||||
'timestamp' => $timestamp,
|
'timestamp' => $timestamp,
|
||||||
'moko_packages' => $mokoPackages,
|
|
||||||
], JSON_UNESCAPED_SLASHES);
|
], JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
// RSA sign the request
|
// RSA sign the request
|
||||||
@@ -364,10 +348,75 @@ class DisplayController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
// Regular Labs Import
|
// Tickets
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
|
|
||||||
public function importRegularLabs()
|
public function createTicket()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
|
if (!$this->checkAcl('mokosuiteclient.tickets.create'))
|
||||||
|
{
|
||||||
|
$this->jsonForbidden();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
|
||||||
|
$this->jsonResponse($this->getModel('Tickets')->createTicket([
|
||||||
|
'subject' => $input->getString('subject', ''),
|
||||||
|
'body' => $input->getRaw('body', ''),
|
||||||
|
'priority' => $input->getString('priority', 'normal'),
|
||||||
|
'category_id' => $input->getInt('category_id', 0),
|
||||||
|
'contact_id' => $input->getInt('contact_id', 0),
|
||||||
|
'assign_users' => $input->get('assign_users', [], 'ARRAY'),
|
||||||
|
'assign_groups' => $input->get('assign_groups', [], 'ARRAY'),
|
||||||
|
'custom_fields' => $input->get('custom_fields', [], 'ARRAY'),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addTicketReply()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
|
if (!$this->checkAcl('mokosuiteclient.tickets'))
|
||||||
|
{
|
||||||
|
$this->jsonForbidden();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
|
||||||
|
$this->jsonResponse($this->getModel('Tickets')->addReply(
|
||||||
|
$input->getInt('ticket_id', 0),
|
||||||
|
$input->getRaw('body', ''),
|
||||||
|
(bool) $input->getInt('is_internal', 0)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateTicketStatus()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
|
if (!$this->checkAcl('mokosuiteclient.tickets'))
|
||||||
|
{
|
||||||
|
$this->jsonForbidden();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
|
||||||
|
$this->jsonResponse($this->getModel('Tickets')->updateStatus(
|
||||||
|
$input->getInt('ticket_id', 0),
|
||||||
|
$input->getInt('status', 0)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// Ticket Settings — Status/Priority CRUD
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
public function saveStatus()
|
||||||
{
|
{
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
@@ -377,165 +426,108 @@ class DisplayController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
$input = Factory::getApplication()->getInput();
|
||||||
{
|
$this->jsonResponse($this->getModel('Tickets')->saveStatus([
|
||||||
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
'id' => $input->getInt('id', 0),
|
||||||
$prefix = $db->getPrefix();
|
'title' => $input->getString('title', ''),
|
||||||
$tables = $db->getTableList();
|
'alias' => $input->getString('alias', ''),
|
||||||
$results = [];
|
'color' => $input->getString('color', 'bg-secondary'),
|
||||||
|
'is_default' => $input->getInt('is_default', 0),
|
||||||
// ── Conditions (4 tables) ──────────────────────────────
|
'is_closed' => $input->getInt('is_closed', 0),
|
||||||
if (in_array($prefix . 'conditions', $tables)
|
'ordering' => $input->getInt('ordering', 0),
|
||||||
&& in_array($prefix . 'mokosuiteclient_conditions', $tables))
|
]));
|
||||||
{
|
|
||||||
// Check if already imported
|
|
||||||
$existing = (int) $db->setQuery("SELECT COUNT(*) FROM " . $db->quoteName('#__mokosuiteclient_conditions'))->loadResult();
|
|
||||||
|
|
||||||
if ($existing === 0)
|
|
||||||
{
|
|
||||||
// conditions
|
|
||||||
$db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_conditions')
|
|
||||||
. " (id, alias, name, description, category, color, match_all, published, hash, checked_out, checked_out_time)"
|
|
||||||
. " SELECT id, alias, name, description, category, color, match_all, published, hash, checked_out, checked_out_time"
|
|
||||||
. " FROM " . $db->quoteName('#__conditions'))->execute();
|
|
||||||
$c1 = $db->getAffectedRows();
|
|
||||||
|
|
||||||
// conditions_groups
|
|
||||||
if (in_array($prefix . 'conditions_groups', $tables))
|
|
||||||
{
|
|
||||||
$db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_conditions_groups')
|
|
||||||
. " (id, condition_id, match_all, ordering)"
|
|
||||||
. " SELECT id, condition_id, match_all, ordering"
|
|
||||||
. " FROM " . $db->quoteName('#__conditions_groups'))->execute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// conditions_rules
|
public function deleteStatus()
|
||||||
if (in_array($prefix . 'conditions_rules', $tables))
|
|
||||||
{
|
|
||||||
$db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_conditions_rules')
|
|
||||||
. " (id, group_id, type, exclude, params, ordering)"
|
|
||||||
. " SELECT id, group_id, type, exclude, params, ordering"
|
|
||||||
. " FROM " . $db->quoteName('#__conditions_rules'))->execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
// conditions_map
|
|
||||||
if (in_array($prefix . 'conditions_map', $tables))
|
|
||||||
{
|
|
||||||
$db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_conditions_map')
|
|
||||||
. " (condition_id, extension, item_id)"
|
|
||||||
. " SELECT condition_id, extension, item_id"
|
|
||||||
. " FROM " . $db->quoteName('#__conditions_map'))->execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
$results['conditions'] = $c1 . ' condition sets imported';
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$results['conditions'] = 'skipped (already has data)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Snippets ──────────────────────────────────────────
|
|
||||||
if (in_array($prefix . 'snippets', $tables)
|
|
||||||
&& in_array($prefix . 'mokosuiteclient_snippets', $tables))
|
|
||||||
{
|
|
||||||
$existing = (int) $db->setQuery("SELECT COUNT(*) FROM " . $db->quoteName('#__mokosuiteclient_snippets'))->loadResult();
|
|
||||||
|
|
||||||
if ($existing === 0)
|
|
||||||
{
|
|
||||||
$db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_snippets')
|
|
||||||
. " (id, alias, name, description, category, color, content, params, published, ordering, checked_out, checked_out_time)"
|
|
||||||
. " SELECT id, alias, name, description, category, color, content, params, published, ordering, checked_out, checked_out_time"
|
|
||||||
. " FROM " . $db->quoteName('#__snippets'))->execute();
|
|
||||||
$results['snippets'] = $db->getAffectedRows() . ' snippets imported';
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$results['snippets'] = 'skipped (already has data)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ReReplacer ────────────────────────────────────────
|
|
||||||
if (in_array($prefix . 'rereplacer', $tables)
|
|
||||||
&& in_array($prefix . 'mokosuiteclient_replacements', $tables))
|
|
||||||
{
|
|
||||||
$existing = (int) $db->setQuery("SELECT COUNT(*) FROM " . $db->quoteName('#__mokosuiteclient_replacements'))->loadResult();
|
|
||||||
|
|
||||||
if ($existing === 0)
|
|
||||||
{
|
|
||||||
// RL uses 'replace' column, we use 'replace_value'; RL 'area' is text (JSON), we use varchar
|
|
||||||
$db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_replacements')
|
|
||||||
. " (id, name, search, replace_value, area, published, description, ordering, checked_out, checked_out_time)"
|
|
||||||
. " SELECT id, name, search, `replace`, 'both', published, description, ordering, checked_out, checked_out_time"
|
|
||||||
. " FROM " . $db->quoteName('#__rereplacer'))->execute();
|
|
||||||
$results['replacements'] = $db->getAffectedRows() . ' replacement rules imported';
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$results['replacements'] = 'skipped (already has data)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Content Templater ─────────────────────────────────
|
|
||||||
if (in_array($prefix . 'contenttemplater', $tables)
|
|
||||||
&& in_array($prefix . 'mokosuiteclient_content_templates', $tables))
|
|
||||||
{
|
|
||||||
$existing = (int) $db->setQuery("SELECT COUNT(*) FROM " . $db->quoteName('#__mokosuiteclient_content_templates'))->loadResult();
|
|
||||||
|
|
||||||
if ($existing === 0)
|
|
||||||
{
|
|
||||||
$db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_content_templates')
|
|
||||||
. " (id, name, description, category, color, template_data, published, ordering, checked_out, checked_out_time)"
|
|
||||||
. " SELECT id, name, description, category, color, content, published, ordering, checked_out, checked_out_time"
|
|
||||||
. " FROM " . $db->quoteName('#__contenttemplater'))->execute();
|
|
||||||
$results['templates'] = $db->getAffectedRows() . ' content templates imported';
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$results['templates'] = 'skipped (already has data)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($results))
|
|
||||||
{
|
|
||||||
$this->jsonResponse(['success' => false, 'message' => 'No Regular Labs data found to import.']);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$summary = implode('; ', array_map(fn($k, $v) => ucfirst($k) . ': ' . $v, array_keys($results), $results));
|
|
||||||
$this->jsonResponse(['success' => true, 'message' => 'Import complete. ' . $summary]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (\Throwable $e)
|
|
||||||
{
|
|
||||||
$this->jsonResponse(['success' => false, 'message' => 'Import error: ' . $e->getMessage()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================================================================
|
|
||||||
// Support PIN
|
|
||||||
// ==================================================================
|
|
||||||
|
|
||||||
public function requestPin()
|
|
||||||
{
|
{
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
if (!$this->checkAcl('mokosuiteclient.dashboard'))
|
if (!$this->checkAcl('core.admin'))
|
||||||
{
|
{
|
||||||
$this->jsonForbidden();
|
$this->jsonForbidden();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||||
|
$this->jsonResponse($this->getModel('Tickets')->deleteStatus($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function savePriority()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
|
if (!$this->checkAcl('core.admin'))
|
||||||
|
{
|
||||||
|
$this->jsonForbidden();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
$this->jsonResponse($this->getModel('Tickets')->savePriority([
|
||||||
|
'id' => $input->getInt('id', 0),
|
||||||
|
'title' => $input->getString('title', ''),
|
||||||
|
'alias' => $input->getString('alias', ''),
|
||||||
|
'color' => $input->getString('color', 'bg-secondary'),
|
||||||
|
'is_default' => $input->getInt('is_default', 0),
|
||||||
|
'ordering' => $input->getInt('ordering', 0),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deletePriority()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
|
if (!$this->checkAcl('core.admin'))
|
||||||
|
{
|
||||||
|
$this->jsonForbidden();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||||
|
$this->jsonResponse($this->getModel('Tickets')->deletePriority($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// KB Search
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
public function searchKb()
|
||||||
|
{
|
||||||
|
$query = Factory::getApplication()->getInput()->getString('q', '');
|
||||||
|
|
||||||
|
if (strlen($query) < 3)
|
||||||
|
{
|
||||||
|
$this->jsonResponse(['results' => []]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
$db = Factory::getDbo();
|
||||||
$result = \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::requestNew($db);
|
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
|
||||||
|
|
||||||
$this->jsonResponse($result);
|
$results = $db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select([$db->quoteName('l.title'), $db->quoteName('l.url'), $db->quoteName('l.description')])
|
||||||
|
->from($db->quoteName('#__finder_links', 'l'))
|
||||||
|
->where($db->quoteName('l.published') . ' = 1')
|
||||||
|
->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped
|
||||||
|
. ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')')
|
||||||
|
->order($db->quoteName('l.title') . ' ASC')
|
||||||
|
->setLimit(8)
|
||||||
|
)->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
foreach ($results as $r)
|
||||||
|
{
|
||||||
|
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->jsonResponse(['results' => $results]);
|
||||||
}
|
}
|
||||||
catch (\Throwable $e)
|
catch (\Throwable $e)
|
||||||
{
|
{
|
||||||
$this->jsonResponse(['success' => false, 'message' => 'Error: ' . $e->getMessage()]);
|
Log::add('KB search failed: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
||||||
|
$this->jsonResponse(['results' => [], 'error' => 'Search unavailable']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,6 +568,218 @@ class DisplayController extends BaseController
|
|||||||
$this->jsonResponse($model->cleanDirectory($dirKey));
|
$this->jsonResponse($model->cleanDirectory($dirKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// Helpdesk CRUD (#137, #138, #139)
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
public function saveCategory()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$id = $input->getInt('id', 0);
|
||||||
|
$data = (object) [
|
||||||
|
'title' => $input->getString('title', ''),
|
||||||
|
'alias' => \Joomla\CMS\Filter\OutputFilter::stringURLSafe($input->getString('title', '')),
|
||||||
|
'sla_response_minutes' => $input->getInt('sla_response_minutes', 480),
|
||||||
|
'sla_resolution_minutes' => $input->getInt('sla_resolution_minutes', 2880),
|
||||||
|
'auto_assign_user' => $input->getInt('auto_assign_user', 0) ?: null,
|
||||||
|
'published' => $input->getInt('published', 1),
|
||||||
|
];
|
||||||
|
if ($id) {
|
||||||
|
$data->id = $id;
|
||||||
|
$db->updateObject('#__mokosuiteclient_ticket_categories', $data, 'id');
|
||||||
|
} else {
|
||||||
|
$data->ordering = 0;
|
||||||
|
$db->insertObject('#__mokosuiteclient_ticket_categories', $data, 'id');
|
||||||
|
}
|
||||||
|
$this->jsonResponse(['success' => true, 'message' => 'Category saved.', 'id' => (int) $data->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteCategory()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_categories')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
|
||||||
|
$this->jsonResponse(['success' => true, 'message' => 'Category deleted.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reorderCategory()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||||
|
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
|
||||||
|
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
foreach ($order as $i => $id) {
|
||||||
|
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_categories') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
|
||||||
|
}
|
||||||
|
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveCanned()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$data = (object) [
|
||||||
|
'title' => $input->getString('title', ''),
|
||||||
|
'body' => $input->getRaw('body', ''),
|
||||||
|
'category_id' => $input->getInt('category_id', 0) ?: null,
|
||||||
|
'ordering' => 0,
|
||||||
|
];
|
||||||
|
$id = $input->getInt('id', 0);
|
||||||
|
if ($id) { $data->id = $id; $db->updateObject('#__mokosuiteclient_ticket_canned', $data, 'id'); }
|
||||||
|
else { $db->insertObject('#__mokosuiteclient_ticket_canned', $data, 'id'); }
|
||||||
|
$this->jsonResponse(['success' => true, 'message' => 'Canned response saved.', 'id' => (int) $data->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteCanned()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_canned')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
|
||||||
|
$this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reorderCanned()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||||
|
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
|
||||||
|
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
foreach ($order as $i => $id) {
|
||||||
|
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_canned') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
|
||||||
|
}
|
||||||
|
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadAttachment()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
$ticketId = $input->getInt('ticket_id', 0);
|
||||||
|
$replyId = $input->getInt('reply_id', 0) ?: null;
|
||||||
|
if (!$ticketId) { $this->jsonResponse(['success' => false, 'message' => 'Missing ticket_id']); return; }
|
||||||
|
$files = $input->files->get('attachments', [], 'raw');
|
||||||
|
if (empty($files) || empty($files['name'])) { $this->jsonResponse(['success' => false, 'message' => 'No files uploaded']); return; }
|
||||||
|
$saved = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::upload($ticketId, $replyId, $files);
|
||||||
|
$this->jsonResponse(['success' => true, 'message' => count($saved) . ' file(s) uploaded', 'count' => count($saved)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadAttachment()
|
||||||
|
{
|
||||||
|
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||||
|
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_ticket_attachments')->where('id = ' . $id));
|
||||||
|
$att = $db->loadObject();
|
||||||
|
if (!$att) { throw new \RuntimeException('Attachment not found', 404); }
|
||||||
|
$path = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getAbsolutePath($att);
|
||||||
|
if (!file_exists($path)) { throw new \RuntimeException('File not found', 404); }
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$app->setHeader('Content-Type', $att->mimetype ?: 'application/octet-stream');
|
||||||
|
$safeName = str_replace(['"', "\r", "\n"], '', $att->filename);
|
||||||
|
$app->setHeader('Content-Disposition', 'attachment; filename="' . $safeName . '"');
|
||||||
|
$app->setHeader('Content-Length', (string) filesize($path));
|
||||||
|
$app->sendHeaders();
|
||||||
|
readfile($path);
|
||||||
|
$app->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteAttachment()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||||
|
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||||
|
$ok = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::delete($id);
|
||||||
|
$this->jsonResponse(['success' => $ok, 'message' => $ok ? 'Attachment deleted' : 'Not found']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rateTicket()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
$ticketId = $input->getInt('ticket_id', 0);
|
||||||
|
$rating = $input->getInt('rating', 0);
|
||||||
|
$feedback = $input->getString('feedback', '');
|
||||||
|
if (!$ticketId || $rating < 1 || $rating > 5) {
|
||||||
|
$this->jsonResponse(['success' => false, 'message' => 'Invalid rating (1-5)']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$db->setQuery(
|
||||||
|
'UPDATE ' . $db->quoteName('#__mokosuiteclient_tickets')
|
||||||
|
. ' SET satisfaction_rating = ' . $rating
|
||||||
|
. ', satisfaction_feedback = ' . $db->quote($feedback)
|
||||||
|
. ', satisfaction_rated_at = ' . $db->quote(Factory::getDate()->toSql())
|
||||||
|
. ' WHERE id = ' . $ticketId
|
||||||
|
)->execute();
|
||||||
|
$this->jsonResponse(['success' => true, 'message' => 'Thank you for your feedback!']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveAutomation()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$data = (object) [
|
||||||
|
'title' => $input->getString('title', ''),
|
||||||
|
'trigger_event' => $input->getString('trigger_event', 'ticket_created'),
|
||||||
|
'conditions' => $input->getRaw('conditions', '[]'),
|
||||||
|
'actions' => $input->getRaw('actions', '[]'),
|
||||||
|
'behavior' => $input->getString('behavior', 'append'),
|
||||||
|
'enabled' => 1,
|
||||||
|
'ordering' => 0,
|
||||||
|
];
|
||||||
|
$id = $input->getInt('id', 0);
|
||||||
|
if ($id) { $data->id = $id; $db->updateObject('#__mokosuiteclient_ticket_automation', $data, 'id'); }
|
||||||
|
else { $db->insertObject('#__mokosuiteclient_ticket_automation', $data, 'id'); }
|
||||||
|
$this->jsonResponse(['success' => true, 'message' => 'Rule saved.', 'id' => (int) $data->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteAutomation()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_automation')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
|
||||||
|
$this->jsonResponse(['success' => true, 'message' => 'Rule deleted.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleAutomation()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$db->setQuery($db->getQuery(true)->update('#__mokosuiteclient_ticket_automation')
|
||||||
|
->set('enabled = ' . $input->getInt('enabled', 0))
|
||||||
|
->where('id = ' . $input->getInt('id', 0)))->execute();
|
||||||
|
$this->jsonResponse(['success' => true, 'message' => 'Rule updated.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reorderAutomation()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||||
|
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
|
||||||
|
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
foreach ($order as $i => $id) {
|
||||||
|
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_automation') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
|
||||||
|
}
|
||||||
|
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
// Settings Import/Export (#132)
|
// Settings Import/Export (#132)
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
@@ -687,7 +891,7 @@ class DisplayController extends BaseController
|
|||||||
{
|
{
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
if (!$this->checkAcl('mokosuiteclient.security.waflog'))
|
if (!$this->checkAcl('core.admin'))
|
||||||
{
|
{
|
||||||
$this->jsonForbidden();
|
$this->jsonForbidden();
|
||||||
return;
|
return;
|
||||||
@@ -703,7 +907,7 @@ class DisplayController extends BaseController
|
|||||||
{
|
{
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
if (!$this->checkAcl('mokosuiteclient.security.waflog'))
|
if (!$this->checkAcl('core.admin'))
|
||||||
{
|
{
|
||||||
$this->jsonForbidden();
|
$this->jsonForbidden();
|
||||||
return;
|
return;
|
||||||
@@ -787,6 +991,19 @@ class DisplayController extends BaseController
|
|||||||
// Importers
|
// Importers
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
|
|
||||||
|
public function importAts()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|
||||||
|
if (!$this->checkAcl('mokosuiteclient.tickets'))
|
||||||
|
{
|
||||||
|
$this->jsonForbidden();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->jsonResponse($this->getModel('Import')->importAts());
|
||||||
|
}
|
||||||
|
|
||||||
public function importAdminTools()
|
public function importAdminTools()
|
||||||
{
|
{
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
|||||||
@@ -1,525 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoSuiteClient
|
|
||||||
* @subpackage com_mokosuiteclient
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoSuiteClient\Administrator\Helper;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\Uri\Uri;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Conditions Engine — evaluates rule-based display conditions.
|
|
||||||
*
|
|
||||||
* Supports nested groups of rules with AND/OR logic and per-rule exclusion.
|
|
||||||
*
|
|
||||||
* @since 02.48.00
|
|
||||||
*/
|
|
||||||
class ConditionsHelper
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Runtime evaluation cache keyed by condition ID.
|
|
||||||
*
|
|
||||||
* @var array<int, bool>
|
|
||||||
*/
|
|
||||||
private static array $cache = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether a condition set passes.
|
|
||||||
*
|
|
||||||
* @param int $conditionId The condition record ID.
|
|
||||||
*
|
|
||||||
* @return bool True when the condition passes (content should display).
|
|
||||||
*/
|
|
||||||
public static function pass(int $conditionId): bool
|
|
||||||
{
|
|
||||||
if (isset(self::$cache[$conditionId])) {
|
|
||||||
return self::$cache[$conditionId];
|
|
||||||
}
|
|
||||||
|
|
||||||
$condition = self::load($conditionId);
|
|
||||||
|
|
||||||
if ($condition === null || !(int) $condition->published) {
|
|
||||||
self::$cache[$conditionId] = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$groups = $condition->groups ?? [];
|
|
||||||
|
|
||||||
if (empty($groups)) {
|
|
||||||
// No groups means no restrictions — pass.
|
|
||||||
self::$cache[$conditionId] = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$matchAll = (bool) $condition->match_all;
|
|
||||||
|
|
||||||
foreach ($groups as $group) {
|
|
||||||
$groupResult = self::passGroup($group);
|
|
||||||
|
|
||||||
if ($matchAll && !$groupResult) {
|
|
||||||
self::$cache[$conditionId] = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$matchAll && $groupResult) {
|
|
||||||
self::$cache[$conditionId] = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// match_all: all passed; match_any: none passed.
|
|
||||||
$result = $matchAll;
|
|
||||||
self::$cache[$conditionId] = $result;
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a condition with its groups and rules from the database.
|
|
||||||
*
|
|
||||||
* @param int $conditionId The condition record ID.
|
|
||||||
*
|
|
||||||
* @return object|null The condition object with nested groups/rules, or null.
|
|
||||||
*/
|
|
||||||
public static function load(int $conditionId): ?object
|
|
||||||
{
|
|
||||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
|
||||||
|
|
||||||
// Load the condition record.
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select('*')
|
|
||||||
->from($db->quoteName('#__mokosuiteclient_conditions'))
|
|
||||||
->where($db->quoteName('id') . ' = :id')
|
|
||||||
->bind(':id', $conditionId, \Joomla\Database\ParameterType::INTEGER);
|
|
||||||
|
|
||||||
$condition = $db->setQuery($query)->loadObject();
|
|
||||||
|
|
||||||
if ($condition === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load groups.
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select('*')
|
|
||||||
->from($db->quoteName('#__mokosuiteclient_conditions_groups'))
|
|
||||||
->where($db->quoteName('condition_id') . ' = :cid')
|
|
||||||
->bind(':cid', $conditionId, \Joomla\Database\ParameterType::INTEGER)
|
|
||||||
->order($db->quoteName('ordering') . ' ASC');
|
|
||||||
|
|
||||||
$groups = $db->setQuery($query)->loadObjectList();
|
|
||||||
|
|
||||||
// Load rules for each group.
|
|
||||||
foreach ($groups as $group) {
|
|
||||||
$groupId = (int) $group->id;
|
|
||||||
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select('*')
|
|
||||||
->from($db->quoteName('#__mokosuiteclient_conditions_rules'))
|
|
||||||
->where($db->quoteName('group_id') . ' = :gid')
|
|
||||||
->bind(':gid', $groupId, \Joomla\Database\ParameterType::INTEGER)
|
|
||||||
->order($db->quoteName('ordering') . ' ASC');
|
|
||||||
|
|
||||||
$group->rules = $db->setQuery($query)->loadObjectList();
|
|
||||||
|
|
||||||
// Decode params JSON on each rule.
|
|
||||||
foreach ($group->rules as $rule) {
|
|
||||||
$rule->params = json_decode($rule->params ?: '{}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$condition->groups = $groups;
|
|
||||||
|
|
||||||
return $condition;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluate a single group (AND/OR its rules).
|
|
||||||
*
|
|
||||||
* @param object $group The group object with a rules array.
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
private static function passGroup(object $group): bool
|
|
||||||
{
|
|
||||||
$rules = $group->rules ?? [];
|
|
||||||
|
|
||||||
if (empty($rules)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$matchAll = (bool) $group->match_all;
|
|
||||||
|
|
||||||
foreach ($rules as $rule) {
|
|
||||||
$ruleResult = self::passRule($rule);
|
|
||||||
|
|
||||||
// If the rule is an exclusion, invert the result.
|
|
||||||
if ((int) $rule->exclude) {
|
|
||||||
$ruleResult = !$ruleResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($matchAll && !$ruleResult) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$matchAll && $ruleResult) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $matchAll;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluate a single rule by dispatching to the right type handler.
|
|
||||||
*
|
|
||||||
* @param object $rule The rule object (type, params decoded).
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
private static function passRule(object $rule): bool
|
|
||||||
{
|
|
||||||
$params = $rule->params ?? new \stdClass();
|
|
||||||
|
|
||||||
return match ($rule->type) {
|
|
||||||
'menu__menu_item' => self::evalMenuMenuItem($params),
|
|
||||||
'menu__home_page' => self::evalMenuHomePage($params),
|
|
||||||
'visitor__user_group' => self::evalVisitorUserGroup($params),
|
|
||||||
'visitor__access_level' => self::evalVisitorAccessLevel($params),
|
|
||||||
'date__date' => self::evalDateDate($params),
|
|
||||||
'date__day' => self::evalDateDay($params),
|
|
||||||
'other__url' => self::evalOtherUrl($params),
|
|
||||||
default => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Rule type evaluators
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* menu__menu_item — check if current menu item ID is in selection.
|
|
||||||
*/
|
|
||||||
private static function evalMenuMenuItem(object $params): bool
|
|
||||||
{
|
|
||||||
$selection = self::toIntArray($params->selection ?? []);
|
|
||||||
|
|
||||||
if (empty($selection)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$app = Factory::getApplication();
|
|
||||||
$itemId = (int) $app->getInput()->getInt('Itemid', 0);
|
|
||||||
|
|
||||||
return \in_array($itemId, $selection, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* menu__home_page — check if current page is the site home page.
|
|
||||||
*/
|
|
||||||
private static function evalMenuHomePage(object $params): bool
|
|
||||||
{
|
|
||||||
$app = Factory::getApplication();
|
|
||||||
$menu = $app->getMenu();
|
|
||||||
|
|
||||||
if ($menu === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$active = $menu->getActive();
|
|
||||||
$default = $menu->getDefault($app->getLanguage()->getTag());
|
|
||||||
|
|
||||||
$isHome = ($active !== null && $default !== null && $active->id === $default->id);
|
|
||||||
|
|
||||||
// params->selection can be [1] for "is home" or [0] for "is not home".
|
|
||||||
$want = (bool) ($params->selection[0] ?? true);
|
|
||||||
|
|
||||||
return $isHome === $want;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* visitor__user_group — check if current user belongs to specified groups.
|
|
||||||
*/
|
|
||||||
private static function evalVisitorUserGroup(object $params): bool
|
|
||||||
{
|
|
||||||
$selection = self::toIntArray($params->selection ?? []);
|
|
||||||
|
|
||||||
if (empty($selection)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = Factory::getApplication()->getIdentity();
|
|
||||||
$userGroups = $user ? $user->getAuthorisedGroups() : [];
|
|
||||||
|
|
||||||
$comparison = $params->comparison ?? 'any';
|
|
||||||
|
|
||||||
if ($comparison === 'all') {
|
|
||||||
return empty(array_diff($selection, $userGroups));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: any
|
|
||||||
return !empty(array_intersect($selection, $userGroups));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* visitor__access_level — check if current user has specified access levels.
|
|
||||||
*/
|
|
||||||
private static function evalVisitorAccessLevel(object $params): bool
|
|
||||||
{
|
|
||||||
$selection = self::toIntArray($params->selection ?? []);
|
|
||||||
|
|
||||||
if (empty($selection)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = Factory::getApplication()->getIdentity();
|
|
||||||
$accessLevels = $user ? $user->getAuthorisedViewLevels() : [];
|
|
||||||
|
|
||||||
$comparison = $params->comparison ?? 'any';
|
|
||||||
|
|
||||||
if ($comparison === 'all') {
|
|
||||||
return empty(array_diff($selection, $accessLevels));
|
|
||||||
}
|
|
||||||
|
|
||||||
return !empty(array_intersect($selection, $accessLevels));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* date__date — check if current date is before/after/between specified dates.
|
|
||||||
*
|
|
||||||
* params->comparison: 'before', 'after', 'between'
|
|
||||||
* params->selection: [start_date] or [start_date, end_date]
|
|
||||||
*/
|
|
||||||
private static function evalDateDate(object $params): bool
|
|
||||||
{
|
|
||||||
$comparison = $params->comparison ?? 'after';
|
|
||||||
$selection = (array) ($params->selection ?? []);
|
|
||||||
|
|
||||||
if (empty($selection)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$now = Factory::getDate()->toUnix();
|
|
||||||
|
|
||||||
return match ($comparison) {
|
|
||||||
'before' => $now < strtotime($selection[0]),
|
|
||||||
'after' => $now > strtotime($selection[0]),
|
|
||||||
'between' => isset($selection[1])
|
|
||||||
&& $now >= strtotime($selection[0])
|
|
||||||
&& $now <= strtotime($selection[1]),
|
|
||||||
default => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* date__day — check if current day of week matches selection.
|
|
||||||
*
|
|
||||||
* params->selection: array of day numbers (1=Monday .. 7=Sunday, ISO-8601).
|
|
||||||
*/
|
|
||||||
private static function evalDateDay(object $params): bool
|
|
||||||
{
|
|
||||||
$selection = self::toIntArray($params->selection ?? []);
|
|
||||||
|
|
||||||
if (empty($selection)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$today = (int) Factory::getDate()->format('N'); // 1=Mon, 7=Sun
|
|
||||||
|
|
||||||
return \in_array($today, $selection, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* other__url — check if current URL matches a regex pattern.
|
|
||||||
*
|
|
||||||
* params->selection: array of regex patterns (without delimiters).
|
|
||||||
*/
|
|
||||||
private static function evalOtherUrl(object $params): bool
|
|
||||||
{
|
|
||||||
$patterns = (array) ($params->selection ?? []);
|
|
||||||
|
|
||||||
if (empty($patterns)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = Uri::getInstance()->toString();
|
|
||||||
|
|
||||||
foreach ($patterns as $pattern) {
|
|
||||||
$pattern = trim($pattern);
|
|
||||||
|
|
||||||
if ($pattern === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap in delimiters, escape internal delimiter.
|
|
||||||
$safePattern = str_replace('#', '\\#', $pattern);
|
|
||||||
if (@preg_match('#' . $safePattern . '#i', $url)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Mapping helpers
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all condition IDs mapped to a specific extension/item pair.
|
|
||||||
*
|
|
||||||
* @param string $extension The extension identifier (e.g. 'mod_custom').
|
|
||||||
* @param int $itemId The item ID within that extension.
|
|
||||||
*
|
|
||||||
* @return int[] Array of condition IDs.
|
|
||||||
*/
|
|
||||||
public static function getConditionsForItem(string $extension, int $itemId): array
|
|
||||||
{
|
|
||||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
|
||||||
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select($db->quoteName('condition_id'))
|
|
||||||
->from($db->quoteName('#__mokosuiteclient_conditions_map'))
|
|
||||||
->where($db->quoteName('extension') . ' = :ext')
|
|
||||||
->where($db->quoteName('item_id') . ' = :iid')
|
|
||||||
->bind(':ext', $extension)
|
|
||||||
->bind(':iid', $itemId, \Joomla\Database\ParameterType::INTEGER);
|
|
||||||
|
|
||||||
return $db->setQuery($query)->loadColumn();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an item should display based on its mapped conditions.
|
|
||||||
*
|
|
||||||
* If no conditions are mapped, the item displays (returns true).
|
|
||||||
* If conditions are mapped, ALL must pass for the item to display.
|
|
||||||
*
|
|
||||||
* @param string $extension The extension identifier.
|
|
||||||
* @param int $itemId The item ID.
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public static function shouldDisplay(string $extension, int $itemId): bool
|
|
||||||
{
|
|
||||||
$conditionIds = self::getConditionsForItem($extension, $itemId);
|
|
||||||
|
|
||||||
if (empty($conditionIds)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($conditionIds as $conditionId) {
|
|
||||||
if (!self::pass((int) $conditionId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluate a condition by its alias string.
|
|
||||||
*
|
|
||||||
* @param string $alias The condition alias.
|
|
||||||
*
|
|
||||||
* @return bool True when the condition passes.
|
|
||||||
*
|
|
||||||
* @since 02.48.00
|
|
||||||
*/
|
|
||||||
public static function passByAlias(string $alias): bool
|
|
||||||
{
|
|
||||||
$id = self::resolveAlias($alias);
|
|
||||||
|
|
||||||
if ($id === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::pass($id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a condition reference that may be an integer ID or an alias string.
|
|
||||||
*
|
|
||||||
* @param string $ref The reference (numeric ID or alias).
|
|
||||||
*
|
|
||||||
* @return int|null The condition ID, or null if not found.
|
|
||||||
*
|
|
||||||
* @since 02.48.00
|
|
||||||
*/
|
|
||||||
public static function resolveAlias(string $ref): ?int
|
|
||||||
{
|
|
||||||
if (is_numeric($ref)) {
|
|
||||||
return (int) $ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
|
||||||
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select($db->quoteName('id'))
|
|
||||||
->from($db->quoteName('#__mokosuiteclient_conditions'))
|
|
||||||
->where($db->quoteName('alias') . ' = :alias')
|
|
||||||
->bind(':alias', $ref);
|
|
||||||
|
|
||||||
$id = $db->setQuery($query)->loadResult();
|
|
||||||
|
|
||||||
return $id !== null ? (int) $id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluate a single inline rule (public wrapper around passRule).
|
|
||||||
*
|
|
||||||
* @param string $type The rule type (e.g. 'visitor__access_level').
|
|
||||||
* @param object $params The rule params object.
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*
|
|
||||||
* @since 02.48.00
|
|
||||||
*/
|
|
||||||
public static function evaluateInlineRule(string $type, object $params): bool
|
|
||||||
{
|
|
||||||
$rule = (object) [
|
|
||||||
'type' => $type,
|
|
||||||
'params' => $params,
|
|
||||||
];
|
|
||||||
|
|
||||||
return self::passRule($rule);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the evaluation cache (useful between requests in testing).
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public static function clearCache(): void
|
|
||||||
{
|
|
||||||
self::$cache = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Internal utilities
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a mixed selection value into an array of integers.
|
|
||||||
*
|
|
||||||
* @param mixed $value Scalar, array, or object.
|
|
||||||
*
|
|
||||||
* @return int[]
|
|
||||||
*/
|
|
||||||
private static function toIntArray(mixed $value): array
|
|
||||||
{
|
|
||||||
if (\is_object($value)) {
|
|
||||||
$value = (array) $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!\is_array($value)) {
|
|
||||||
$value = [$value];
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_map('intval', array_values($value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoSuiteClient
|
|
||||||
* @subpackage com_mokosuiteclient
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoSuiteClient\Administrator\Helper;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared Support PIN helper.
|
|
||||||
*
|
|
||||||
* Generates HMAC-based support PINs from the core plugin's health token.
|
|
||||||
* Used by the component dashboard, cpanel module, cache module, and
|
|
||||||
* the requestPin AJAX controller.
|
|
||||||
*
|
|
||||||
* @since 02.48.00
|
|
||||||
*/
|
|
||||||
class SupportPinHelper
|
|
||||||
{
|
|
||||||
/** @var int Default PIN validity window in seconds (72 hours) */
|
|
||||||
public const PIN_TTL_DEFAULT = 72 * 3600;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load core plugin params and return PIN state.
|
|
||||||
*
|
|
||||||
* @param DatabaseInterface $db Database driver.
|
|
||||||
*
|
|
||||||
* @return array{available: bool, pin: string, token: string, params: array}
|
|
||||||
*/
|
|
||||||
public static function getState(DatabaseInterface $db): array
|
|
||||||
{
|
|
||||||
$result = [
|
|
||||||
'available' => false,
|
|
||||||
'pin' => '',
|
|
||||||
'token' => '',
|
|
||||||
'params' => [],
|
|
||||||
'ext_id' => 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select([$db->quoteName('extension_id'), $db->quoteName('params')])
|
|
||||||
->from($db->quoteName('#__extensions'))
|
|
||||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
|
|
||||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
|
||||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
|
|
||||||
|
|
||||||
$db->setQuery($query);
|
|
||||||
$ext = $db->loadObject();
|
|
||||||
|
|
||||||
if (!$ext)
|
|
||||||
{
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
$params = json_decode($ext->params, true) ?: [];
|
|
||||||
$token = $params['health_api_token'] ?? '';
|
|
||||||
|
|
||||||
$result['params'] = $params;
|
|
||||||
$result['ext_id'] = (int) $ext->extension_id;
|
|
||||||
$result['token'] = $token;
|
|
||||||
|
|
||||||
if (empty($token))
|
|
||||||
{
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
$result['available'] = true;
|
|
||||||
|
|
||||||
$pinTtl = (int) ($params['support_pin_hours'] ?? 0) * 3600 ?: self::PIN_TTL_DEFAULT;
|
|
||||||
$requestedAt = (int) ($params['support_pin_requested_at'] ?? 0);
|
|
||||||
|
|
||||||
if ($requestedAt && (time() - $requestedAt) < $pinTtl)
|
|
||||||
{
|
|
||||||
$result['pin'] = self::generate($token, $requestedAt, $pinTtl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (\Throwable $e)
|
|
||||||
{
|
|
||||||
// Silently degrade — PIN is non-critical UI sugar
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a PIN string from a token and timestamp.
|
|
||||||
*
|
|
||||||
* @param string $token Health API token (HMAC key).
|
|
||||||
* @param int $timestamp The request timestamp.
|
|
||||||
* @param int $ttl PIN validity window in seconds.
|
|
||||||
*
|
|
||||||
* @return string e.g. "MOKO-A1B2-C3D4"
|
|
||||||
*/
|
|
||||||
public static function generate(string $token, int $timestamp, int $ttl = 0): string
|
|
||||||
{
|
|
||||||
$ttl = $ttl ?: self::PIN_TTL_DEFAULT;
|
|
||||||
$window = floor($timestamp / $ttl);
|
|
||||||
$hash = hash_hmac('sha256', (string) $window, $token);
|
|
||||||
|
|
||||||
return 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request a new PIN: stamps the current time into plugin params and returns the PIN.
|
|
||||||
*
|
|
||||||
* @param DatabaseInterface $db Database driver.
|
|
||||||
*
|
|
||||||
* @return array{success: bool, pin?: string, message: string}
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Render PIN badge HTML (active PIN with copy, or request button).
|
|
||||||
*
|
|
||||||
* @param array $state Result from getState().
|
|
||||||
* @param string $token CSRF form token name.
|
|
||||||
* @param string $context 'dashboard'|'cpanel'|'cache' — controls layout variant.
|
|
||||||
*
|
|
||||||
* @return string HTML fragment (no wrapping div).
|
|
||||||
*/
|
|
||||||
public static function renderBadge(array $state, string $token, string $context = 'dashboard'): string
|
|
||||||
{
|
|
||||||
if (!$state['available'])
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$requestUrl = \Joomla\CMS\Router\Route::_('index.php?option=com_mokosuiteclient&task=display.requestPin&format=json');
|
|
||||||
$pin = $state['pin'];
|
|
||||||
|
|
||||||
$html = '';
|
|
||||||
|
|
||||||
if (!empty($pin))
|
|
||||||
{
|
|
||||||
$escaped = htmlspecialchars($pin, ENT_QUOTES, 'UTF-8');
|
|
||||||
|
|
||||||
if ($context === 'cache')
|
|
||||||
{
|
|
||||||
$html .= '<a href="#" class="btn btn-sm btn-outline-secondary rounded-0 border-end-0 d-flex align-items-center gap-1 px-3 py-2 mokosuiteclient-pin-copy" data-pin="' . $escaped . '" title="Support PIN — click to copy" style="font-size:0.8rem;">';
|
|
||||||
$html .= '<span class="icon-key" aria-hidden="true"></span>';
|
|
||||||
$html .= '<span class="mokosuiteclient-pin-text">' . $escaped . '</span>';
|
|
||||||
$html .= '</a>';
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$html .= '<span class="badge bg-dark mokosuiteclient-pin-copy" style="font-family:monospace;letter-spacing:0.08em;cursor:pointer;" title="Click to copy" data-pin="' . $escaped . '">';
|
|
||||||
$html .= '<span class="icon-key small me-1" aria-hidden="true"></span><span class="mokosuiteclient-pin-text">' . $escaped . '</span></span>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if ($context === 'cache')
|
|
||||||
{
|
|
||||||
$html .= '<a href="#" class="btn btn-sm btn-outline-secondary rounded-0 border-end-0 d-flex align-items-center gap-1 px-3 py-2 mokosuiteclient-pin-request" data-url="' . $requestUrl . '" data-token="' . $token . '" title="Request support PIN" style="font-size:0.8rem;">';
|
|
||||||
$html .= '<span class="icon-key" aria-hidden="true"></span>';
|
|
||||||
$html .= '<span class="mokosuiteclient-pin-text">PIN</span>';
|
|
||||||
$html .= '</a>';
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$html .= '<button type="button" class="btn btn-sm btn-outline-dark py-0 px-2 mokosuiteclient-pin-request" data-url="' . $requestUrl . '" data-token="' . $token . '" style="font-size:0.75rem;" title="Request a support PIN">';
|
|
||||||
$html .= '<span class="icon-key" aria-hidden="true"></span> Request PIN</button>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render shared JS for PIN copy and request functionality.
|
|
||||||
*
|
|
||||||
* @return string <script> block.
|
|
||||||
*/
|
|
||||||
public static function renderScript(): string
|
|
||||||
{
|
|
||||||
return <<<'JS'
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
if (window._mokoPinBound) return;
|
|
||||||
window._mokoPinBound = true;
|
|
||||||
|
|
||||||
function bindCopy(el) {
|
|
||||||
if (el.dataset.bound) return;
|
|
||||||
el.dataset.bound = '1';
|
|
||||||
if (!el.title) el.title = 'Click to copy';
|
|
||||||
el.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
var pin = this.dataset.pin;
|
|
||||||
if (pin && navigator.clipboard) {
|
|
||||||
var textEl = this.querySelector('.mokosuiteclient-pin-text');
|
|
||||||
navigator.clipboard.writeText(pin).then(function() {
|
|
||||||
if (textEl) {
|
|
||||||
textEl.textContent = 'Copied!';
|
|
||||||
setTimeout(function() { textEl.textContent = pin; }, 5000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
document.querySelectorAll('.mokosuiteclient-pin-copy').forEach(bindCopy);
|
|
||||||
document.querySelectorAll('.mokosuiteclient-pin-request').forEach(function(el) {
|
|
||||||
if (el.dataset.bound) return;
|
|
||||||
el.dataset.bound = '1';
|
|
||||||
if (!el.title) el.title = 'Request a support PIN';
|
|
||||||
el.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (this.dataset.busy) return;
|
|
||||||
this.dataset.busy = '1';
|
|
||||||
var btn = this;
|
|
||||||
var textEl = btn.querySelector('.mokosuiteclient-pin-text');
|
|
||||||
var origHtml = btn.innerHTML;
|
|
||||||
if (textEl) { textEl.textContent = '...'; } else { btn.textContent = '...'; }
|
|
||||||
var fd = new FormData();
|
|
||||||
fd.append(btn.dataset.token, '1');
|
|
||||||
fetch(btn.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
|
||||||
if (data.success && data.pin) {
|
|
||||||
btn.classList.remove('mokosuiteclient-pin-request');
|
|
||||||
btn.classList.add('mokosuiteclient-pin-copy');
|
|
||||||
btn.dataset.pin = data.pin;
|
|
||||||
btn.title = 'Click to copy';
|
|
||||||
if (textEl) {
|
|
||||||
textEl.textContent = data.pin;
|
|
||||||
} else {
|
|
||||||
btn.className = 'badge bg-dark mokosuiteclient-pin-copy';
|
|
||||||
btn.style = 'font-family:monospace;letter-spacing:0.08em;cursor:pointer;';
|
|
||||||
btn.innerHTML = '<span class="icon-key small me-1" aria-hidden="true"></span><span class="mokosuiteclient-pin-text">' + data.pin + '</span>';
|
|
||||||
}
|
|
||||||
btn.dataset.bound = '';
|
|
||||||
bindCopy(btn);
|
|
||||||
} else {
|
|
||||||
Joomla.renderMessages({error: [data.message || 'Failed to generate PIN']});
|
|
||||||
btn.innerHTML = origHtml;
|
|
||||||
}
|
|
||||||
delete btn.dataset.busy;
|
|
||||||
})
|
|
||||||
.catch(function() {
|
|
||||||
Joomla.renderMessages({error: ['Network error']});
|
|
||||||
btn.innerHTML = origHtml;
|
|
||||||
delete btn.dataset.busy;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
JS;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function requestNew(DatabaseInterface $db): array
|
|
||||||
{
|
|
||||||
$state = self::getState($db);
|
|
||||||
|
|
||||||
if (!$state['available'])
|
|
||||||
{
|
|
||||||
return ['success' => false, 'message' => 'Health token not configured.'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$now = time();
|
|
||||||
$params = $state['params'];
|
|
||||||
$params['support_pin_requested_at'] = $now;
|
|
||||||
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->update($db->quoteName('#__extensions'))
|
|
||||||
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
|
|
||||||
->where($db->quoteName('extension_id') . ' = ' . $state['ext_id']);
|
|
||||||
|
|
||||||
$db->setQuery($query)->execute();
|
|
||||||
|
|
||||||
$pinHours = (int) ($params['support_pin_hours'] ?? 0) ?: (int) (self::PIN_TTL_DEFAULT / 3600);
|
|
||||||
$pinTtl = $pinHours * 3600;
|
|
||||||
$pin = self::generate($state['token'], $now, $pinTtl);
|
|
||||||
|
|
||||||
return ['success' => true, 'pin' => $pin, 'message' => 'PIN generated — valid for ' . $pinHours . ' hours.'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -77,30 +77,6 @@ class DashboardModel extends BaseDatabaseModel
|
|||||||
'protected' => false,
|
'protected' => false,
|
||||||
'configure_only' => true,
|
'configure_only' => true,
|
||||||
],
|
],
|
||||||
'mokosuiteclient_backup' => [
|
|
||||||
'icon' => 'icon-archive',
|
|
||||||
'category' => 'monitoring',
|
|
||||||
'label' => 'Backup Bridge',
|
|
||||||
'description' => 'Detects MokoSuiteBackup and sends backup status in heartbeat payloads to HQ.',
|
|
||||||
'protected' => false,
|
|
||||||
'configure_only' => true,
|
|
||||||
],
|
|
||||||
'mokosuiteclient_dbip' => [
|
|
||||||
'icon' => 'icon-globe',
|
|
||||||
'category' => 'security',
|
|
||||||
'label' => 'GeoIP Lookup',
|
|
||||||
'description' => 'Country-level IP geolocation using DB-IP lite database for WAF and analytics.',
|
|
||||||
'protected' => false,
|
|
||||||
'configure_only' => true,
|
|
||||||
],
|
|
||||||
'mokosuiteclient_license' => [
|
|
||||||
'icon' => 'icon-key',
|
|
||||||
'category' => 'tools',
|
|
||||||
'label' => 'License Manager',
|
|
||||||
'description' => 'Download key management and license validation for MokoSuite packages.',
|
|
||||||
'protected' => false,
|
|
||||||
'configure_only' => true,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,46 +213,30 @@ class DashboardModel extends BaseDatabaseModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover all installed MokoSuite ecosystem extensions.
|
* Get installed MokoSuiteClient component and modules with versions.
|
||||||
*
|
*
|
||||||
* Fuzzy-matches packages, components, modules, plugins, and libraries
|
* @return array Array of extension objects with name, element, type, version.
|
||||||
* by element name containing "mokosuite", "mokosuiteclient", "mokojoom",
|
|
||||||
* or "moko" prefix patterns.
|
|
||||||
*
|
|
||||||
* @return array Extension objects with name, element, type, version, enabled, family.
|
|
||||||
*/
|
*/
|
||||||
public function getMokoExtensions(): array
|
public function getMokoExtensions(): array
|
||||||
{
|
{
|
||||||
$db = $this->getDatabase();
|
$db = $this->getDatabase();
|
||||||
$el = $db->quoteName('element');
|
|
||||||
|
|
||||||
// Fuzzy match: any extension whose element contains moko patterns
|
|
||||||
$patterns = [
|
|
||||||
$el . ' LIKE ' . $db->quote('pkg_mokosuite%'),
|
|
||||||
$el . ' LIKE ' . $db->quote('com_mokosuite%'),
|
|
||||||
$el . ' LIKE ' . $db->quote('mod_mokosuite%'),
|
|
||||||
$el . ' LIKE ' . $db->quote('mokosuite%'),
|
|
||||||
$el . ' LIKE ' . $db->quote('mokosuiteclient%'),
|
|
||||||
$el . ' LIKE ' . $db->quote('pkg_mokojoom%'),
|
|
||||||
$el . ' LIKE ' . $db->quote('com_mokojoom%'),
|
|
||||||
$el . ' LIKE ' . $db->quote('mod_mokojoom%'),
|
|
||||||
$el . ' LIKE ' . $db->quote('mokojoom%'),
|
|
||||||
$el . ' LIKE ' . $db->quote('plg_%_mokosuite%'),
|
|
||||||
$el . ' LIKE ' . $db->quote('plg_%_mokojoom%'),
|
|
||||||
];
|
|
||||||
|
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select([
|
->select([
|
||||||
$db->quoteName('extension_id'),
|
|
||||||
$db->quoteName('element'),
|
$db->quoteName('element'),
|
||||||
$db->quoteName('name'),
|
$db->quoteName('name'),
|
||||||
$db->quoteName('type'),
|
$db->quoteName('type'),
|
||||||
$db->quoteName('folder'),
|
|
||||||
$db->quoteName('enabled'),
|
$db->quoteName('enabled'),
|
||||||
$db->quoteName('manifest_cache'),
|
$db->quoteName('manifest_cache'),
|
||||||
])
|
])
|
||||||
->from($db->quoteName('#__extensions'))
|
->from($db->quoteName('#__extensions'))
|
||||||
->where('(' . implode(' OR ', $patterns) . ')')
|
->where('('
|
||||||
|
// The component
|
||||||
|
. '(' . $db->quoteName('type') . ' = ' . $db->quote('component')
|
||||||
|
. ' AND ' . $db->quoteName('element') . ' = ' . $db->quote('com_mokosuiteclient') . ')'
|
||||||
|
// Admin modules
|
||||||
|
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('module')
|
||||||
|
. ' AND ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mod_mokosuiteclient%') . ')'
|
||||||
|
. ')')
|
||||||
->order($db->quoteName('type') . ' ASC, ' . $db->quoteName('element') . ' ASC');
|
->order($db->quoteName('type') . ' ASC, ' . $db->quoteName('element') . ' ASC');
|
||||||
|
|
||||||
$db->setQuery($query);
|
$db->setQuery($query);
|
||||||
@@ -288,27 +248,12 @@ class DashboardModel extends BaseDatabaseModel
|
|||||||
{
|
{
|
||||||
$manifest = json_decode($row->manifest_cache ?? '{}');
|
$manifest = json_decode($row->manifest_cache ?? '{}');
|
||||||
|
|
||||||
// Determine product family from element name
|
|
||||||
$family = 'mokosuite';
|
|
||||||
if (stripos($row->element, 'mokosuiteclient') !== false) {
|
|
||||||
$family = 'mokosuiteclient';
|
|
||||||
} elseif (stripos($row->element, 'mokosuitehq') !== false) {
|
|
||||||
$family = 'mokosuitehq';
|
|
||||||
} elseif (stripos($row->element, 'mokosuitecrm') !== false) {
|
|
||||||
$family = 'mokosuitecrm';
|
|
||||||
} elseif (stripos($row->element, 'mokojoom') !== false) {
|
|
||||||
$family = 'mokojoom';
|
|
||||||
}
|
|
||||||
|
|
||||||
$extensions[] = (object) [
|
$extensions[] = (object) [
|
||||||
'extension_id' => (int) $row->extension_id,
|
|
||||||
'element' => $row->element,
|
'element' => $row->element,
|
||||||
'name' => $manifest->name ?? $row->name,
|
'name' => $manifest->name ?? $row->name,
|
||||||
'type' => $row->type,
|
'type' => $row->type,
|
||||||
'folder' => $row->folder ?? '',
|
|
||||||
'version' => $manifest->version ?? '',
|
'version' => $manifest->version ?? '',
|
||||||
'enabled' => (int) $row->enabled,
|
'enabled' => (int) $row->enabled,
|
||||||
'family' => $family,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class ExtensionsModel extends BaseDatabaseModel
|
|||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
|
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
|
||||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||||
$data = curl_exec($ch);
|
$data = curl_exec($ch);
|
||||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
$error = curl_error($ch);
|
$error = curl_error($ch);
|
||||||
@@ -221,7 +221,7 @@ class ExtensionsModel extends BaseDatabaseModel
|
|||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
@@ -238,15 +238,8 @@ class ExtensionsModel extends BaseDatabaseModel
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dev channel only available on Moko domains; all others forced to stable
|
// Determine site's update channel preference
|
||||||
$isMokoDomain = (bool) preg_match('/\.mokoconsulting\.tech$/i', $_SERVER['HTTP_HOST'] ?? '');
|
$channel = 'dev'; // default to dev — show everything
|
||||||
$channel = 'stable';
|
|
||||||
if ($isMokoDomain) {
|
|
||||||
try {
|
|
||||||
$channel = \Joomla\CMS\Component\ComponentHelper::getParams('com_installer')
|
|
||||||
->get('update_channel', 'stable') ?: 'stable';
|
|
||||||
} catch (\Throwable $e) {}
|
|
||||||
}
|
|
||||||
$hasStable = false;
|
$hasStable = false;
|
||||||
$hasDev = false;
|
$hasDev = false;
|
||||||
|
|
||||||
@@ -276,18 +269,7 @@ class ExtensionsModel extends BaseDatabaseModel
|
|||||||
$hasDev = true;
|
$hasDev = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($ver === '')
|
if ($ver === '' || version_compare($ver, $bestVersion, '<='))
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Respect update channel: stable channel skips dev-tagged versions
|
|
||||||
if ($channel === 'stable' && $tag === 'dev')
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (version_compare($ver, $bestVersion, '<='))
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteClient
|
||||||
|
* @subpackage com_mokosuiteclient
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoSuiteClient\Administrator\Service;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\Filesystem\File;
|
||||||
|
use Joomla\CMS\Filesystem\Folder;
|
||||||
|
use Joomla\CMS\Log\Log;
|
||||||
|
|
||||||
|
class AttachmentService
|
||||||
|
{
|
||||||
|
private const STORAGE_DIR = JPATH_ROOT . '/media/com_mokosuiteclient/attachments';
|
||||||
|
|
||||||
|
private const ALLOWED_EXTENSIONS = [
|
||||||
|
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
||||||
|
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'txt', 'rtf',
|
||||||
|
'zip', 'gz', 'tar',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload file(s) for a ticket or reply.
|
||||||
|
*
|
||||||
|
* @param int $ticketId Ticket ID
|
||||||
|
* @param int|null $replyId Reply ID (null for ticket-level attachments)
|
||||||
|
* @param array $files $_FILES array entry (single or multi)
|
||||||
|
* @return array Saved attachment records
|
||||||
|
*/
|
||||||
|
public static function upload(int $ticketId, ?int $replyId, array $files): array
|
||||||
|
{
|
||||||
|
$saved = [];
|
||||||
|
|
||||||
|
// Normalize single file to array format
|
||||||
|
if (!is_array($files['name'])) {
|
||||||
|
$files = [
|
||||||
|
'name' => [$files['name']],
|
||||||
|
'type' => [$files['type']],
|
||||||
|
'tmp_name' => [$files['tmp_name']],
|
||||||
|
'error' => [$files['error']],
|
||||||
|
'size' => [$files['size']],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketDir = self::STORAGE_DIR . '/' . $ticketId;
|
||||||
|
|
||||||
|
if (!is_dir($ticketDir) && !Folder::create($ticketDir)) {
|
||||||
|
Log::add("Failed to create attachment directory: {$ticketDir}", Log::ERROR, 'mokosuiteclient');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int) Factory::getUser()->id;
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
|
for ($i = 0, $count = count($files['name']); $i < $count; $i++)
|
||||||
|
{
|
||||||
|
if ($files['error'][$i] !== UPLOAD_ERR_OK) {
|
||||||
|
Log::add("Attachment upload error for '{$files['name'][$i]}': PHP error code {$files['error'][$i]}", Log::WARNING, 'mokosuiteclient');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$originalName = File::makeSafe($files['name'][$i]);
|
||||||
|
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
// Validate extension
|
||||||
|
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||||
|
Log::add("Attachment rejected: disallowed extension .{$ext}", Log::WARNING, 'mokosuiteclient');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate size
|
||||||
|
if ($files['size'][$i] > self::MAX_FILE_SIZE) {
|
||||||
|
Log::add("Attachment rejected: file too large ({$files['size'][$i]} bytes)", Log::WARNING, 'mokosuiteclient');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename to prevent overwrites
|
||||||
|
$storedName = uniqid('att_', true) . '.' . $ext;
|
||||||
|
$destPath = $ticketDir . '/' . $storedName;
|
||||||
|
|
||||||
|
if (!File::upload($files['tmp_name'][$i], $destPath)) {
|
||||||
|
Log::add("Attachment upload failed: {$originalName}", Log::ERROR, 'mokosuiteclient');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = (object) [
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'reply_id' => $replyId,
|
||||||
|
'filename' => $originalName,
|
||||||
|
'filepath' => $ticketId . '/' . $storedName,
|
||||||
|
'filesize' => $files['size'][$i],
|
||||||
|
'mimetype' => mime_content_type($destPath) ?: 'application/octet-stream',
|
||||||
|
'uploaded_by' => $userId,
|
||||||
|
'created' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$db->insertObject('#__mokosuiteclient_ticket_attachments', $record, 'id');
|
||||||
|
$saved[] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get attachments for a ticket.
|
||||||
|
*/
|
||||||
|
public static function getForTicket(int $ticketId): array
|
||||||
|
{
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('a.*, u.name AS uploader_name')
|
||||||
|
->from($db->quoteName('#__mokosuiteclient_ticket_attachments', 'a'))
|
||||||
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = a.uploaded_by')
|
||||||
|
->where($db->quoteName('a.ticket_id') . ' = ' . $ticketId)
|
||||||
|
->order('a.created ASC')
|
||||||
|
);
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the absolute filesystem path for an attachment.
|
||||||
|
*/
|
||||||
|
public static function getAbsolutePath(object $attachment): ?string
|
||||||
|
{
|
||||||
|
$path = realpath(self::STORAGE_DIR . '/' . $attachment->filepath);
|
||||||
|
if ($path === false || !str_starts_with($path, realpath(self::STORAGE_DIR))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an attachment (file + DB record).
|
||||||
|
*/
|
||||||
|
public static function delete(int $attachmentId): bool
|
||||||
|
{
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from('#__mokosuiteclient_ticket_attachments')
|
||||||
|
->where('id = ' . $attachmentId)
|
||||||
|
);
|
||||||
|
$att = $db->loadObject();
|
||||||
|
|
||||||
|
if (!$att) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = self::STORAGE_DIR . '/' . $att->filepath;
|
||||||
|
|
||||||
|
if (file_exists($path)) {
|
||||||
|
File::delete($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->delete('#__mokosuiteclient_ticket_attachments')
|
||||||
|
->where('id = ' . $attachmentId)
|
||||||
|
)->execute();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size for display.
|
||||||
|
*/
|
||||||
|
public static function formatSize(int $bytes): string
|
||||||
|
{
|
||||||
|
if ($bytes < 1024) return $bytes . ' B';
|
||||||
|
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
|
||||||
|
return round($bytes / 1048576, 1) . ' MB';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteClient
|
||||||
|
* @subpackage com_mokosuiteclient
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoSuiteClient\Administrator\Service;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\Log\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automation rule engine — evaluates trigger/condition/action rules.
|
||||||
|
*
|
||||||
|
* Called from event hooks (system plugin, task plugin) whenever
|
||||||
|
* a triggering event occurs. Loads matching rules, checks conditions,
|
||||||
|
* and executes actions.
|
||||||
|
*
|
||||||
|
* @since 02.35.00
|
||||||
|
*/
|
||||||
|
class AutomationEngine
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Fire all matching rules for a given trigger event.
|
||||||
|
*
|
||||||
|
* @param string $triggerEvent Event name (ticket_created, user_login, etc.)
|
||||||
|
* @param array $context Context data (ticket object, user data, etc.)
|
||||||
|
*/
|
||||||
|
public static function fire(string $triggerEvent, array $context = []): void
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$rules = self::getActiveRules($triggerEvent);
|
||||||
|
|
||||||
|
foreach ($rules as $rule)
|
||||||
|
{
|
||||||
|
$conditions = json_decode($rule->conditions, true) ?: [];
|
||||||
|
$actions = json_decode($rule->actions, true) ?: [];
|
||||||
|
|
||||||
|
if (self::evaluateConditions($conditions, $context))
|
||||||
|
{
|
||||||
|
self::executeActions($actions, $rule, $context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
Log::add('Automation engine error: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active automation rules for a trigger event.
|
||||||
|
*/
|
||||||
|
private static function getActiveRules(string $event): array
|
||||||
|
{
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from('#__mokosuiteclient_ticket_automation')
|
||||||
|
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
|
||||||
|
->where($db->quoteName('enabled') . ' = 1')
|
||||||
|
->order('ordering ASC')
|
||||||
|
);
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate all conditions (AND logic).
|
||||||
|
*/
|
||||||
|
private static function evaluateConditions(array $conditions, array $context): bool
|
||||||
|
{
|
||||||
|
foreach ($conditions as $c)
|
||||||
|
{
|
||||||
|
$field = $c['field'] ?? '';
|
||||||
|
$op = $c['op'] ?? 'eq';
|
||||||
|
$expected = $c['value'] ?? '';
|
||||||
|
$actual = $context[$field] ?? '';
|
||||||
|
|
||||||
|
switch ($op)
|
||||||
|
{
|
||||||
|
case 'eq': if ((string) $actual !== (string) $expected) return false; break;
|
||||||
|
case 'neq': if ((string) $actual === (string) $expected) return false; break;
|
||||||
|
case 'gt': if ((float) $actual <= (float) $expected) return false; break;
|
||||||
|
case 'lt': if ((float) $actual >= (float) $expected) return false; break;
|
||||||
|
case 'in':
|
||||||
|
$values = array_map('trim', explode(',', $expected));
|
||||||
|
if (!in_array((string) $actual, $values, true)) return false;
|
||||||
|
break;
|
||||||
|
case 'not_in':
|
||||||
|
$values = array_map('trim', explode(',', $expected));
|
||||||
|
if (in_array((string) $actual, $values, true)) return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute actions for a matched rule.
|
||||||
|
*/
|
||||||
|
private static function executeActions(array $actions, object $rule, array $context): void
|
||||||
|
{
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$ticketId = (int) ($context['ticket_id'] ?? $context['id'] ?? 0);
|
||||||
|
|
||||||
|
foreach ($actions as $action)
|
||||||
|
{
|
||||||
|
$type = $action['type'] ?? '';
|
||||||
|
$value = $action['value'] ?? '';
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch ($type)
|
||||||
|
{
|
||||||
|
case 'set_status':
|
||||||
|
if ($ticketId) {
|
||||||
|
$statusId = self::resolveStatusId($db, $value);
|
||||||
|
$sets = "status = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())}";
|
||||||
|
if ($statusId) { $sets .= ", status_id = {$statusId}"; }
|
||||||
|
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'set_priority':
|
||||||
|
if ($ticketId) {
|
||||||
|
$priorityId = self::resolvePriorityId($db, $value);
|
||||||
|
$sets = "priority = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())}";
|
||||||
|
if ($priorityId) { $sets .= ", priority_id = {$priorityId}"; }
|
||||||
|
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'assign':
|
||||||
|
$assignId = (int) $value;
|
||||||
|
if ($ticketId && $assignId > 0) {
|
||||||
|
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET assigned_to = {$assignId}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'add_note':
|
||||||
|
if ($ticketId) {
|
||||||
|
$note = (object) [
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'user_id' => 0,
|
||||||
|
'body' => $value ?: '[Automation: ' . ($rule->title ?? '') . ']',
|
||||||
|
'is_internal' => 1,
|
||||||
|
'created' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
$db->insertObject('#__mokosuiteclient_ticket_replies', $note);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'send_email':
|
||||||
|
NotificationService::securityAlert(
|
||||||
|
'automation',
|
||||||
|
'Automation: ' . ($rule->title ?? ''),
|
||||||
|
$value ?: 'Rule triggered for ticket #' . $ticketId
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'send_ntfy':
|
||||||
|
NotificationService::pushNtfySecurity(
|
||||||
|
'automation',
|
||||||
|
'Automation: ' . ($rule->title ?? ''),
|
||||||
|
$value ?: 'Rule triggered for ticket #' . $ticketId
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'close':
|
||||||
|
if ($ticketId) {
|
||||||
|
$closedId = self::resolveClosedStatusId($db);
|
||||||
|
$sets = "status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}, modified = {$db->quote(Factory::getDate()->toSql())}";
|
||||||
|
if ($closedId) { $sets .= ", status_id = {$closedId}"; }
|
||||||
|
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'create_ticket':
|
||||||
|
self::createTicketFromAutomation($rule, $context, $value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
Log::add("Automation action '{$type}' failed for rule #{$rule->id}: " . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a ticket from automation (with behavior: append/always_new/skip_if_open).
|
||||||
|
*/
|
||||||
|
private static function resolveStatusId($db, string $alias): int
|
||||||
|
{
|
||||||
|
return (int) $db->setQuery(
|
||||||
|
$db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
|
||||||
|
->where($db->quoteName('alias') . ' = ' . $db->quote($alias)), 0, 1
|
||||||
|
)->loadResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolvePriorityId($db, string $alias): int
|
||||||
|
{
|
||||||
|
return (int) $db->setQuery(
|
||||||
|
$db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_priorities')
|
||||||
|
->where($db->quoteName('alias') . ' = ' . $db->quote($alias)), 0, 1
|
||||||
|
)->loadResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveClosedStatusId($db): int
|
||||||
|
{
|
||||||
|
return (int) $db->setQuery(
|
||||||
|
$db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
|
||||||
|
->where($db->quoteName('is_closed') . ' = 1'), 0, 1
|
||||||
|
)->loadResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function createTicketFromAutomation(object $rule, array $context, string $subject): void
|
||||||
|
{
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$behavior = $rule->behavior ?? 'append';
|
||||||
|
$userId = (int) ($context['user_id'] ?? 0);
|
||||||
|
$catId = (int) ($context['category_id'] ?? 0);
|
||||||
|
|
||||||
|
if ($behavior !== 'always_new' && $userId > 0)
|
||||||
|
{
|
||||||
|
// Check for existing open ticket (check both status ENUM and status_id)
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('t.id')
|
||||||
|
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
||||||
|
->join('LEFT', $db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON t.status_id = s.id')
|
||||||
|
->where('t.created_by = ' . $userId)
|
||||||
|
->where("(s.id IS NULL AND t.status NOT IN ('closed', 'resolved')) OR (s.id IS NOT NULL AND s.is_closed = 0)");
|
||||||
|
|
||||||
|
if ($catId > 0) {
|
||||||
|
$query->where('category_id = ' . $catId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->setQuery($query, 0, 1);
|
||||||
|
$existingId = (int) $db->loadResult();
|
||||||
|
|
||||||
|
if ($existingId > 0)
|
||||||
|
{
|
||||||
|
if ($behavior === 'skip_if_open') return;
|
||||||
|
|
||||||
|
// append — add reply to existing ticket
|
||||||
|
$reply = (object) [
|
||||||
|
'ticket_id' => $existingId,
|
||||||
|
'user_id' => 0,
|
||||||
|
'body' => $subject ?: '[Automation: ' . ($rule->title ?? '') . ']',
|
||||||
|
'is_internal' => 1,
|
||||||
|
'created' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new ticket
|
||||||
|
$openStatusId = self::resolveStatusId($db, 'open') ?: null;
|
||||||
|
$normalPriorityId = self::resolvePriorityId($db, $context['priority'] ?? 'normal') ?: null;
|
||||||
|
$ticket = (object) [
|
||||||
|
'subject' => $subject ?: 'Automation: ' . ($rule->title ?? ''),
|
||||||
|
'body' => $context['body'] ?? '',
|
||||||
|
'status' => 'open',
|
||||||
|
'status_id' => $openStatusId,
|
||||||
|
'priority' => $context['priority'] ?? 'normal',
|
||||||
|
'priority_id' => $normalPriorityId,
|
||||||
|
'category_id' => $catId ?: null,
|
||||||
|
'created_by' => $userId,
|
||||||
|
'created' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
$db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteClient
|
||||||
|
* @subpackage com_mokosuiteclient
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoSuiteClient\Administrator\Service;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\Log\Log;
|
||||||
|
use Joomla\CMS\Uri\Uri;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpdesk email notification service.
|
||||||
|
*
|
||||||
|
* Sends emails for ticket events to Joomla users (by ID) and/or
|
||||||
|
* raw email addresses. Uses Joomla's configured mailer.
|
||||||
|
*
|
||||||
|
* @since 02.32.00
|
||||||
|
*/
|
||||||
|
class NotificationService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Send a ticket notification email.
|
||||||
|
*
|
||||||
|
* @param string $event Event name (ticket_created, ticket_replied, status_changed, ticket_assigned)
|
||||||
|
* @param object $ticket Ticket object with id, subject, status, priority, created_by, assigned_to
|
||||||
|
* @param array $extra Extra context (reply body, old status, etc.)
|
||||||
|
*/
|
||||||
|
public static function notify(string $event, object $ticket, array $extra = []): void
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$recipients = self::getRecipients($event, $ticket);
|
||||||
|
|
||||||
|
if (empty($recipients))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subject = self::buildSubject($event, $ticket);
|
||||||
|
$body = self::buildBody($event, $ticket, $extra);
|
||||||
|
|
||||||
|
$mailer = Factory::getMailer();
|
||||||
|
$mailer->isHtml(false);
|
||||||
|
$mailer->setSubject($subject);
|
||||||
|
$mailer->setBody($body);
|
||||||
|
|
||||||
|
foreach ($recipients as $email)
|
||||||
|
{
|
||||||
|
$email = trim($email);
|
||||||
|
|
||||||
|
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$mailer->clearAddresses();
|
||||||
|
$mailer->addRecipient($email);
|
||||||
|
$mailer->Send();
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push notification via ntfy
|
||||||
|
self::pushNtfy($event, $ticket, $subject);
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
Log::add('Notification error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine recipients based on event type and ticket data.
|
||||||
|
*/
|
||||||
|
private static function getRecipients(string $event, object $ticket): array
|
||||||
|
{
|
||||||
|
$emails = [];
|
||||||
|
|
||||||
|
// Get notification config from component params
|
||||||
|
$config = self::getNotificationConfig();
|
||||||
|
|
||||||
|
// Always notify configured admin emails
|
||||||
|
$adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? '')));
|
||||||
|
$emails = array_merge($emails, $adminEmails);
|
||||||
|
|
||||||
|
// Always notify configured admin user IDs
|
||||||
|
$adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? '')));
|
||||||
|
|
||||||
|
foreach ($adminUserIds as $uid)
|
||||||
|
{
|
||||||
|
$email = self::getUserEmail($uid);
|
||||||
|
|
||||||
|
if ($email)
|
||||||
|
{
|
||||||
|
$emails[] = $email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($event)
|
||||||
|
{
|
||||||
|
case 'ticket_created':
|
||||||
|
// Notify assigned user if any
|
||||||
|
if (!empty($ticket->assigned_to))
|
||||||
|
{
|
||||||
|
$email = self::getUserEmail((int) $ticket->assigned_to);
|
||||||
|
|
||||||
|
if ($email)
|
||||||
|
{
|
||||||
|
$emails[] = $email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ticket_replied':
|
||||||
|
// Notify ticket creator (customer gets notified of staff reply)
|
||||||
|
if (!empty($ticket->created_by))
|
||||||
|
{
|
||||||
|
$email = self::getUserEmail((int) $ticket->created_by);
|
||||||
|
|
||||||
|
if ($email)
|
||||||
|
{
|
||||||
|
$emails[] = $email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify assigned user
|
||||||
|
if (!empty($ticket->assigned_to))
|
||||||
|
{
|
||||||
|
$email = self::getUserEmail((int) $ticket->assigned_to);
|
||||||
|
|
||||||
|
if ($email)
|
||||||
|
{
|
||||||
|
$emails[] = $email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'status_changed':
|
||||||
|
// Notify ticket creator
|
||||||
|
if (!empty($ticket->created_by))
|
||||||
|
{
|
||||||
|
$email = self::getUserEmail((int) $ticket->created_by);
|
||||||
|
|
||||||
|
if ($email)
|
||||||
|
{
|
||||||
|
$emails[] = $email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ticket_assigned':
|
||||||
|
// Notify newly assigned user
|
||||||
|
if (!empty($ticket->assigned_to))
|
||||||
|
{
|
||||||
|
$email = self::getUserEmail((int) $ticket->assigned_to);
|
||||||
|
|
||||||
|
if ($email)
|
||||||
|
{
|
||||||
|
$emails[] = $email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_unique($emails);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build email subject line.
|
||||||
|
*/
|
||||||
|
private static function buildSubject(string $event, object $ticket): string
|
||||||
|
{
|
||||||
|
$siteName = Factory::getConfig()->get('sitename', 'Support');
|
||||||
|
$prefix = '[' . $siteName . ' #' . $ticket->id . '] ';
|
||||||
|
|
||||||
|
switch ($event)
|
||||||
|
{
|
||||||
|
case 'ticket_created':
|
||||||
|
return $prefix . 'New Ticket: ' . ($ticket->subject ?? '');
|
||||||
|
|
||||||
|
case 'ticket_replied':
|
||||||
|
return $prefix . 'Reply: ' . ($ticket->subject ?? '');
|
||||||
|
|
||||||
|
case 'status_changed':
|
||||||
|
return $prefix . 'Status Changed: ' . ($ticket->subject ?? '');
|
||||||
|
|
||||||
|
case 'ticket_assigned':
|
||||||
|
return $prefix . 'Assigned: ' . ($ticket->subject ?? '');
|
||||||
|
|
||||||
|
default:
|
||||||
|
return $prefix . ($ticket->subject ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build email body.
|
||||||
|
*/
|
||||||
|
private static function buildBody(string $event, object $ticket, array $extra): string
|
||||||
|
{
|
||||||
|
$siteName = Factory::getConfig()->get('sitename', 'Support');
|
||||||
|
$siteUrl = rtrim(Uri::root(), '/');
|
||||||
|
$ticketUrl = $siteUrl . '/index.php?option=com_mokosuiteclient&view=ticket&id=' . $ticket->id;
|
||||||
|
|
||||||
|
$lines = [];
|
||||||
|
$lines[] = $siteName . ' Support';
|
||||||
|
$lines[] = str_repeat('-', 40);
|
||||||
|
$lines[] = '';
|
||||||
|
|
||||||
|
switch ($event)
|
||||||
|
{
|
||||||
|
case 'ticket_created':
|
||||||
|
$lines[] = 'A new support ticket has been created.';
|
||||||
|
$lines[] = '';
|
||||||
|
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
||||||
|
$lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal');
|
||||||
|
$lines[] = 'Category: ' . ($ticket->category_title ?? 'General');
|
||||||
|
$lines[] = '';
|
||||||
|
|
||||||
|
if (!empty($ticket->body))
|
||||||
|
{
|
||||||
|
$lines[] = 'Description:';
|
||||||
|
$lines[] = strip_tags($ticket->body);
|
||||||
|
$lines[] = '';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ticket_replied':
|
||||||
|
$lines[] = 'A new reply has been added to your ticket.';
|
||||||
|
$lines[] = '';
|
||||||
|
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
||||||
|
$lines[] = 'Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? ''));
|
||||||
|
$lines[] = '';
|
||||||
|
|
||||||
|
if (!empty($extra['reply_body']))
|
||||||
|
{
|
||||||
|
$lines[] = 'Reply:';
|
||||||
|
$lines[] = strip_tags($extra['reply_body']);
|
||||||
|
$lines[] = '';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'status_changed':
|
||||||
|
$lines[] = 'Your ticket status has been updated.';
|
||||||
|
$lines[] = '';
|
||||||
|
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
||||||
|
$lines[] = 'New Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? ''));
|
||||||
|
|
||||||
|
if (!empty($extra['old_status']))
|
||||||
|
{
|
||||||
|
$lines[] = 'Old Status: ' . ucwords(str_replace('_', ' ', $extra['old_status']));
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines[] = '';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ticket_assigned':
|
||||||
|
$lines[] = 'A ticket has been assigned to you.';
|
||||||
|
$lines[] = '';
|
||||||
|
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
||||||
|
$lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal');
|
||||||
|
$lines[] = '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines[] = 'View ticket: ' . $ticketUrl;
|
||||||
|
$lines[] = '';
|
||||||
|
$lines[] = '-- ';
|
||||||
|
$lines[] = $siteName . ' | Powered by MokoSuiteClient';
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get email address for a Joomla user ID.
|
||||||
|
*/
|
||||||
|
private static function getUserEmail(int $userId): ?string
|
||||||
|
{
|
||||||
|
if ($userId <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select($db->quoteName('email'))
|
||||||
|
->from($db->quoteName('#__users'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $db->loadResult() ?: null;
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
Log::add('Failed to look up email for user ID ' . $userId . ': ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification configuration from component params.
|
||||||
|
*/
|
||||||
|
private static function getNotificationConfig(): array
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select($db->quoteName('params'))
|
||||||
|
->from($db->quoteName('#__extensions'))
|
||||||
|
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuiteclient'))
|
||||||
|
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||||
|
);
|
||||||
|
|
||||||
|
$params = json_decode($db->loadResult() ?? '{}', true);
|
||||||
|
|
||||||
|
return $params['notifications'] ?? [];
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
Log::add('Failed to load notification config: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// Ntfy Push Notifications (#205)
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a push notification via ntfy for ticket events.
|
||||||
|
*/
|
||||||
|
private static function pushNtfy(string $event, object $ticket, string $title): void
|
||||||
|
{
|
||||||
|
$config = self::getNotificationConfig();
|
||||||
|
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
|
||||||
|
|
||||||
|
if (!$ntfyEnabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
|
||||||
|
$ntfyTopic = $config['ntfy_topic'] ?? 'mokosuiteclient-tickets';
|
||||||
|
$ntfyToken = $config['ntfy_token'] ?? '';
|
||||||
|
|
||||||
|
$tagMap = [
|
||||||
|
'ticket_created' => 'ticket,new',
|
||||||
|
'ticket_replied' => 'speech_balloon',
|
||||||
|
'status_changed' => 'arrows_counterclockwise',
|
||||||
|
'ticket_assigned' => 'bust_in_silhouette',
|
||||||
|
];
|
||||||
|
|
||||||
|
$priorityMap = [
|
||||||
|
'ticket_created' => '4',
|
||||||
|
'ticket_replied' => '3',
|
||||||
|
'status_changed' => '3',
|
||||||
|
'ticket_assigned' => '3',
|
||||||
|
];
|
||||||
|
|
||||||
|
$siteUrl = rtrim(Uri::root(), '/');
|
||||||
|
$ticketUrl = $siteUrl . '/administrator/index.php?option=com_mokosuiteclient&view=ticket&id=' . ($ticket->id ?? 0);
|
||||||
|
|
||||||
|
$message = self::buildNtfyMessage($event, $ticket);
|
||||||
|
|
||||||
|
$headers = [
|
||||||
|
'Title: ' . $title,
|
||||||
|
'Priority: ' . ($priorityMap[$event] ?? '3'),
|
||||||
|
'Tags: ' . ($tagMap[$event] ?? 'ticket'),
|
||||||
|
'Click: ' . $ticketUrl,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($ntfyToken !== '')
|
||||||
|
{
|
||||||
|
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $ntfyServer . '/' . $ntfyTopic;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $message);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($response === false)
|
||||||
|
{
|
||||||
|
Log::add("Ntfy push connection failed for event {$event}: " . $curlError, Log::WARNING, 'mokosuiteclient');
|
||||||
|
}
|
||||||
|
elseif ($httpCode < 200 || $httpCode >= 300)
|
||||||
|
{
|
||||||
|
Log::add("Ntfy push failed (HTTP {$httpCode}) for event {$event}", Log::WARNING, 'mokosuiteclient');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
Log::add('Ntfy push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a short ntfy message body for ticket events.
|
||||||
|
*/
|
||||||
|
private static function buildNtfyMessage(string $event, object $ticket): string
|
||||||
|
{
|
||||||
|
$subject = $ticket->subject ?? 'Ticket #' . ($ticket->id ?? '?');
|
||||||
|
|
||||||
|
switch ($event)
|
||||||
|
{
|
||||||
|
case 'ticket_created':
|
||||||
|
$priority = ucfirst($ticket->priority ?? 'normal');
|
||||||
|
return "New ticket: {$subject}\nPriority: {$priority}";
|
||||||
|
|
||||||
|
case 'ticket_replied':
|
||||||
|
return "Reply on: {$subject}";
|
||||||
|
|
||||||
|
case 'status_changed':
|
||||||
|
$status = ucwords(str_replace('_', ' ', $ticket->status ?? ''));
|
||||||
|
return "Status → {$status}: {$subject}";
|
||||||
|
|
||||||
|
case 'ticket_assigned':
|
||||||
|
return "Assigned to you: {$subject}";
|
||||||
|
|
||||||
|
default:
|
||||||
|
return $subject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a push notification via ntfy for security events.
|
||||||
|
*/
|
||||||
|
public static function pushNtfySecurity(string $event, string $title, string $body): void
|
||||||
|
{
|
||||||
|
$config = self::getNotificationConfig();
|
||||||
|
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
|
||||||
|
|
||||||
|
if (!$ntfyEnabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
|
||||||
|
$ntfyTopic = $config['ntfy_security_topic'] ?? $config['ntfy_topic'] ?? 'mokosuiteclient-security';
|
||||||
|
$ntfyToken = $config['ntfy_token'] ?? '';
|
||||||
|
|
||||||
|
$headers = [
|
||||||
|
'Title: [Security] ' . $title,
|
||||||
|
'Priority: 5',
|
||||||
|
'Tags: warning,shield',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($ntfyToken !== '')
|
||||||
|
{
|
||||||
|
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $ntfyServer . '/' . $ntfyTopic;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
Log::add('Ntfy security push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// Security Event Notifications (#131)
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a security alert to admin emails.
|
||||||
|
*/
|
||||||
|
public static function securityAlert(string $event, string $subject, string $body): void
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$config = self::getNotificationConfig();
|
||||||
|
$enabled = $config['security_alerts'] ?? '1';
|
||||||
|
|
||||||
|
if (!$enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? '')));
|
||||||
|
$adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? '')));
|
||||||
|
|
||||||
|
$recipients = $adminEmails;
|
||||||
|
|
||||||
|
foreach ($adminUserIds as $uid)
|
||||||
|
{
|
||||||
|
$email = self::getUserEmail($uid);
|
||||||
|
|
||||||
|
if ($email)
|
||||||
|
{
|
||||||
|
$recipients[] = $email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$recipients = array_unique($recipients);
|
||||||
|
|
||||||
|
if (empty($recipients))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$siteName = Factory::getConfig()->get('sitename', 'Site');
|
||||||
|
$fullSubject = '[' . $siteName . ' Security] ' . $subject;
|
||||||
|
|
||||||
|
$lines = [
|
||||||
|
$siteName . ' Security Alert',
|
||||||
|
str_repeat('-', 40),
|
||||||
|
'',
|
||||||
|
'Event: ' . $event,
|
||||||
|
'Time: ' . gmdate('Y-m-d H:i:s') . ' UTC',
|
||||||
|
'',
|
||||||
|
$body,
|
||||||
|
'',
|
||||||
|
'-- ',
|
||||||
|
$siteName . ' | MokoSuiteClient Security',
|
||||||
|
];
|
||||||
|
|
||||||
|
$mailer = Factory::getMailer();
|
||||||
|
$mailer->isHtml(false);
|
||||||
|
$mailer->setSubject($fullSubject);
|
||||||
|
$mailer->setBody(implode("\n", $lines));
|
||||||
|
|
||||||
|
foreach ($recipients as $email)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$mailer->clearAddresses();
|
||||||
|
$mailer->addRecipient(trim($email));
|
||||||
|
$mailer->Send();
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also push via ntfy
|
||||||
|
self::pushNtfySecurity($event, $subject, $body);
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
Log::add('Security alert error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@ class HtmlView extends BaseHtmlView
|
|||||||
|
|
||||||
ToolbarHelper::title('Automation Rules', 'cogs');
|
ToolbarHelper::title('Automation Rules', 'cogs');
|
||||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
|
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
|
||||||
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Automation');
|
|
||||||
|
|
||||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||||
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
|
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
namespace Moko\Component\MokoSuiteClient\Administrator\View\Canned;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
|
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||||
|
|
||||||
|
class HtmlView extends BaseHtmlView
|
||||||
|
{
|
||||||
|
protected $responses = [];
|
||||||
|
protected $categories = [];
|
||||||
|
|
||||||
|
public function display($tpl = null)
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||||
|
|
||||||
|
$db->setQuery('SELECT * FROM #__mokosuiteclient_ticket_canned ORDER BY ordering ASC');
|
||||||
|
$this->responses = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
$db->setQuery('SELECT id, title FROM #__mokosuiteclient_ticket_categories WHERE published = 1 ORDER BY ordering');
|
||||||
|
$this->categories = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
ToolbarHelper::title('Canned Responses', 'comment');
|
||||||
|
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
|
||||||
|
|
||||||
|
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||||
|
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
|
||||||
|
|
||||||
|
parent::display($tpl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,6 @@ class HtmlView extends BaseHtmlView
|
|||||||
|
|
||||||
ToolbarHelper::title('Ticket Categories', 'folder');
|
ToolbarHelper::title('Ticket Categories', 'folder');
|
||||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
|
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
|
||||||
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Categories');
|
|
||||||
|
|
||||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||||
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
|
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ class HtmlView extends BaseHtmlView
|
|||||||
|
|
||||||
ToolbarHelper::title('Cache & Temp Cleanup', 'trash');
|
ToolbarHelper::title('Cache & Temp Cleanup', 'trash');
|
||||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
|
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
|
||||||
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Cleanup');
|
|
||||||
|
|
||||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||||
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
|
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ class HtmlView extends BaseHtmlView
|
|||||||
protected $loginChartData = [];
|
protected $loginChartData = [];
|
||||||
protected $mokoExtensions = [];
|
protected $mokoExtensions = [];
|
||||||
public $supportPin = '';
|
public $supportPin = '';
|
||||||
public $supportPinAvailable = false;
|
|
||||||
public $regularLabsAvailable = false;
|
|
||||||
|
|
||||||
public function display($tpl = null)
|
public function display($tpl = null)
|
||||||
{
|
{
|
||||||
@@ -38,23 +36,26 @@ class HtmlView extends BaseHtmlView
|
|||||||
$this->siteInfo = $model->getSiteInfo();
|
$this->siteInfo = $model->getSiteInfo();
|
||||||
|
|
||||||
// Daily support PIN from health token
|
// Daily support PIN from health token
|
||||||
$pinState = \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::getState(
|
try
|
||||||
\Joomla\CMS\Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class)
|
{
|
||||||
|
$db = \Joomla\CMS\Factory::getDbo();
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select($db->quoteName('params'))
|
||||||
|
->from($db->quoteName('#__extensions'))
|
||||||
|
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
|
||||||
|
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||||
|
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||||
);
|
);
|
||||||
$this->supportPinAvailable = $pinState['available'];
|
$token = (json_decode((string) $db->loadResult()))->health_api_token ?? '';
|
||||||
$this->supportPin = $pinState['pin'];
|
|
||||||
|
|
||||||
// Detect Regular Labs data for import
|
|
||||||
try {
|
|
||||||
$rlDb = \Joomla\CMS\Factory::getDbo();
|
|
||||||
$rlTables = $rlDb->getTableList();
|
|
||||||
$rlPrefix = $rlDb->getPrefix();
|
|
||||||
$this->regularLabsAvailable = in_array($rlPrefix . 'conditions', $rlTables)
|
|
||||||
|| in_array($rlPrefix . 'snippets', $rlTables)
|
|
||||||
|| in_array($rlPrefix . 'rereplacer', $rlTables)
|
|
||||||
|| in_array($rlPrefix . 'contenttemplater', $rlTables);
|
|
||||||
} catch (\Throwable $e) {}
|
|
||||||
|
|
||||||
|
if (!empty($token))
|
||||||
|
{
|
||||||
|
$hash = hash_hmac('sha256', gmdate('Y-m-d'), $token);
|
||||||
|
$this->supportPin = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (\Throwable $e) {}
|
||||||
$this->recentLogins = $model->getRecentLogins(10);
|
$this->recentLogins = $model->getRecentLogins(10);
|
||||||
$this->pendingUpdates = $model->getPendingUpdates();
|
$this->pendingUpdates = $model->getPendingUpdates();
|
||||||
$this->checkedOutItems = $model->getCheckedOutItems();
|
$this->checkedOutItems = $model->getCheckedOutItems();
|
||||||
@@ -95,7 +96,5 @@ class HtmlView extends BaseHtmlView
|
|||||||
{
|
{
|
||||||
ToolbarHelper::preferences('com_mokosuiteclient');
|
ToolbarHelper::preferences('com_mokosuiteclient');
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Dashboard');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ class HtmlView extends BaseHtmlView
|
|||||||
|
|
||||||
ToolbarHelper::title('Database Tools', 'database');
|
ToolbarHelper::title('Database Tools', 'database');
|
||||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
|
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
|
||||||
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Database');
|
|
||||||
|
|
||||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||||
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
|
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ class HtmlView extends BaseHtmlView
|
|||||||
$this->pipelineData = $model->getPipelineReport($this->dateFrom, $this->dateTo);
|
$this->pipelineData = $model->getPipelineReport($this->dateFrom, $this->dateTo);
|
||||||
$this->agingData = $model->getAgingReceivables();
|
$this->agingData = $model->getAgingReceivables();
|
||||||
ToolbarHelper::title('ERP Reports', 'icon-chart-bar');
|
ToolbarHelper::title('ERP Reports', 'icon-chart-bar');
|
||||||
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/ERP-Reports');
|
|
||||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||||
$wa->registerAndUseStyle('com_mokosuiteclient.erp', 'com_mokosuiteclient/erp.css');
|
$wa->registerAndUseStyle('com_mokosuiteclient.erp', 'com_mokosuiteclient/erp.css');
|
||||||
$wa->registerAndUseScript('com_mokosuiteclient.erp-dashboard', 'com_mokosuiteclient/erp-dashboard.js', [], ['defer' => true]);
|
$wa->registerAndUseScript('com_mokosuiteclient.erp-dashboard', 'com_mokosuiteclient/erp-dashboard.js', [], ['defer' => true]);
|
||||||
|
|||||||
@@ -37,6 +37,5 @@ class HtmlView extends BaseHtmlView
|
|||||||
{
|
{
|
||||||
ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_EXTENSIONS_TITLE'), 'puzzle-piece');
|
ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_EXTENSIONS_TITLE'), 'puzzle-piece');
|
||||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
|
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
|
||||||
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Extensions');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,5 @@ class HtmlView extends BaseHtmlView
|
|||||||
{
|
{
|
||||||
ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_HTACCESS_TITLE'), 'file-code');
|
ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_HTACCESS_TITLE'), 'file-code');
|
||||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
|
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
|
||||||
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Htaccess');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,5 @@ class HtmlView extends BaseHtmlView
|
|||||||
{
|
{
|
||||||
ToolbarHelper::title('Privacy Guard', 'lock');
|
ToolbarHelper::title('Privacy Guard', 'lock');
|
||||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
|
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
|
||||||
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/Privacy');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,5 @@ class HtmlView extends BaseHtmlView
|
|||||||
{
|
{
|
||||||
ToolbarHelper::title('WAF Log Viewer', 'shield-alt');
|
ToolbarHelper::title('WAF Log Viewer', 'shield-alt');
|
||||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
|
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
|
||||||
ToolbarHelper::help('', false, 'https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/wiki/WAF-Log');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
|
|
||||||
|
$responses = $this->responses;
|
||||||
|
$categories = $this->categories;
|
||||||
|
$token = Session::getFormToken();
|
||||||
|
$saveUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.saveCanned&format=json');
|
||||||
|
$deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteCanned&format=json');
|
||||||
|
$reorderUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.reorderCanned&format=json');
|
||||||
|
|
||||||
|
// Build category map for filter display
|
||||||
|
$catMap = [0 => 'All Categories'];
|
||||||
|
foreach ($categories as $cat)
|
||||||
|
{
|
||||||
|
$catMap[$cat->id] = $cat->title;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div id="mokosuiteclient-canned">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<h4 class="mb-0"><?php echo count($responses); ?> Canned Responses</h4>
|
||||||
|
<select id="canned-filter-category" class="form-select form-select-sm" style="width:auto;">
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
<?php foreach ($categories as $cat): ?>
|
||||||
|
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" onclick="openCannedModal(0)">
|
||||||
|
<span class="icon-plus"></span> Add Response
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="canned-list">
|
||||||
|
<?php foreach ($responses as $r): ?>
|
||||||
|
<div class="card mb-2 canned-card" data-id="<?php echo $r->id; ?>" data-category="<?php echo (int) $r->category_id; ?>" style="cursor:grab;">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="flex-grow-1" style="cursor:pointer;" onclick="openCannedModal(<?php echo $r->id; ?>)">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="icon-menu text-muted" style="cursor:grab;" title="Drag to reorder"></span>
|
||||||
|
<strong><?php echo htmlspecialchars($r->title); ?></strong>
|
||||||
|
<?php if (!empty($r->category_id) && isset($catMap[$r->category_id])): ?>
|
||||||
|
<span class="badge bg-secondary"><?php echo htmlspecialchars($catMap[$r->category_id]); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0 mt-1 ms-4"><?php echo htmlspecialchars(mb_substr(strip_tags($r->body), 0, 150)); ?></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-canned" data-id="<?php echo $r->id; ?>">
|
||||||
|
<span class="icon-trash"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php if (empty($responses)): ?>
|
||||||
|
<div class="alert alert-info" id="canned-empty">No canned responses yet. Click "Add Response" to create one.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Canned Response Modal (create + edit) -->
|
||||||
|
<div class="modal fade" id="cannedModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 id="cannedModalTitle">Add Canned Response</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="canned-id" value="0">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Title</label>
|
||||||
|
<input type="text" id="canned-title" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Category (optional)</label>
|
||||||
|
<select id="canned-category" class="form-select">
|
||||||
|
<option value="">No category</option>
|
||||||
|
<?php foreach ($categories as $cat): ?>
|
||||||
|
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Response Text</label>
|
||||||
|
<textarea id="canned-body" class="form-control" rows="8" required></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="btn-save-canned"><span class="icon-save"></span> Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var tokenKey = '<?php echo $token; ?>';
|
||||||
|
|
||||||
|
// ── Response data store (for edit modal) ────────────────────
|
||||||
|
var responseData = {};
|
||||||
|
<?php foreach ($responses as $r): ?>
|
||||||
|
responseData[<?php echo $r->id; ?>] = {
|
||||||
|
title: <?php echo json_encode($r->title); ?>,
|
||||||
|
body: <?php echo json_encode($r->body); ?>,
|
||||||
|
category_id: <?php echo json_encode($r->category_id ?? ''); ?>
|
||||||
|
};
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
// ── Open modal for create (id=0) or edit ────────────────────
|
||||||
|
window.openCannedModal = function(id) {
|
||||||
|
document.getElementById('canned-id').value = id;
|
||||||
|
if (id > 0 && responseData[id]) {
|
||||||
|
document.getElementById('cannedModalTitle').textContent = 'Edit Canned Response';
|
||||||
|
document.getElementById('canned-title').value = responseData[id].title;
|
||||||
|
document.getElementById('canned-body').value = responseData[id].body;
|
||||||
|
document.getElementById('canned-category').value = responseData[id].category_id || '';
|
||||||
|
} else {
|
||||||
|
document.getElementById('cannedModalTitle').textContent = 'Add Canned Response';
|
||||||
|
document.getElementById('canned-title').value = '';
|
||||||
|
document.getElementById('canned-body').value = '';
|
||||||
|
document.getElementById('canned-category').value = '';
|
||||||
|
}
|
||||||
|
new bootstrap.Modal(document.getElementById('cannedModal')).show();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Save (create or update) ─────────────────────────────────
|
||||||
|
document.getElementById('btn-save-canned').addEventListener('click', function() {
|
||||||
|
var title = document.getElementById('canned-title').value.trim();
|
||||||
|
if (!title) { Joomla.renderMessages({error:['Title is required']}); return; }
|
||||||
|
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('id', document.getElementById('canned-id').value);
|
||||||
|
fd.append('title', title);
|
||||||
|
fd.append('body', document.getElementById('canned-body').value);
|
||||||
|
fd.append('category_id', document.getElementById('canned-category').value);
|
||||||
|
fd.append(tokenKey, '1');
|
||||||
|
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||||
|
.then(function(r){return r.json()})
|
||||||
|
.then(function(d){
|
||||||
|
if (d.success) location.reload();
|
||||||
|
else Joomla.renderMessages({error:[d.message]});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete ──────────────────────────────────────────────────
|
||||||
|
document.querySelectorAll('.btn-delete-canned').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm('Delete this canned response?')) return;
|
||||||
|
var card = this.closest('.card');
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('id', this.dataset.id);
|
||||||
|
fd.append(tokenKey, '1');
|
||||||
|
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||||
|
.then(function(r){return r.json()})
|
||||||
|
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Category filter ─────────────────────────────────────────
|
||||||
|
document.getElementById('canned-filter-category').addEventListener('change', function() {
|
||||||
|
var catId = this.value;
|
||||||
|
document.querySelectorAll('.canned-card').forEach(function(card) {
|
||||||
|
if (!catId || card.dataset.category === catId) {
|
||||||
|
card.style.display = '';
|
||||||
|
} else {
|
||||||
|
card.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Drag-and-drop reorder ───────────────────────────────────
|
||||||
|
var list = document.getElementById('canned-list');
|
||||||
|
var dragCard = null;
|
||||||
|
|
||||||
|
list.addEventListener('dragstart', function(e) {
|
||||||
|
dragCard = e.target.closest('.canned-card');
|
||||||
|
if (dragCard) {
|
||||||
|
dragCard.style.opacity = '0.5';
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
list.addEventListener('dragend', function() {
|
||||||
|
if (dragCard) dragCard.style.opacity = '';
|
||||||
|
dragCard = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
list.addEventListener('dragover', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var target = e.target.closest('.canned-card');
|
||||||
|
if (target && target !== dragCard) {
|
||||||
|
var rect = target.getBoundingClientRect();
|
||||||
|
var after = (e.clientY - rect.top) > rect.height / 2;
|
||||||
|
if (after) {
|
||||||
|
target.parentNode.insertBefore(dragCard, target.nextSibling);
|
||||||
|
} else {
|
||||||
|
target.parentNode.insertBefore(dragCard, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
list.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
// Persist new order
|
||||||
|
var ids = [];
|
||||||
|
document.querySelectorAll('.canned-card').forEach(function(c) { ids.push(c.dataset.id); });
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('order', JSON.stringify(ids));
|
||||||
|
fd.append(tokenKey, '1');
|
||||||
|
fetch('<?php echo $reorderUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make cards draggable
|
||||||
|
document.querySelectorAll('.canned-card').forEach(function(card) {
|
||||||
|
card.setAttribute('draggable', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -25,9 +25,6 @@ $atsAvail = $this->atsAvailable ?? null;
|
|||||||
$checkedOut = $this->checkedOutItems;
|
$checkedOut = $this->checkedOutItems;
|
||||||
$wafBlocks = $this->wafBlocks;
|
$wafBlocks = $this->wafBlocks;
|
||||||
$token = Session::getFormToken();
|
$token = Session::getFormToken();
|
||||||
$user = \Joomla\CMS\Factory::getApplication()->getIdentity();
|
|
||||||
$canWafLog = $user->authorise('mokosuiteclient.security.waflog', 'com_mokosuiteclient')
|
|
||||||
|| $user->authorise('core.admin', 'com_mokosuiteclient');
|
|
||||||
|
|
||||||
// Group plugins by category
|
// Group plugins by category
|
||||||
$grouped = [];
|
$grouped = [];
|
||||||
@@ -56,11 +53,8 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
|||||||
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1.1rem;color:#1a2744"></span>
|
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1.1rem;color:#1a2744"></span>
|
||||||
<span class="fw-bold"><?php echo $this->escape($siteInfo->sitename); ?></span>
|
<span class="fw-bold"><?php echo $this->escape($siteInfo->sitename); ?></span>
|
||||||
<span class="badge bg-primary">MokoSuite <?php echo $this->escape($siteInfo->mokosuiteclient_version); ?></span>
|
<span class="badge bg-primary">MokoSuite <?php echo $this->escape($siteInfo->mokosuiteclient_version); ?></span>
|
||||||
<?php echo \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::renderBadge(
|
|
||||||
['available' => !empty($this->supportPinAvailable), 'pin' => $this->supportPin ?? ''],
|
|
||||||
$token, 'dashboard'
|
|
||||||
); ?>
|
|
||||||
<?php if (!empty($this->supportPin)): ?>
|
<?php if (!empty($this->supportPin)): ?>
|
||||||
|
<span class="badge bg-dark" style="font-family:monospace;letter-spacing:0.08em;cursor:help;" title="Daily verification PIN — rotates at midnight UTC."><span class="icon-key small me-1" aria-hidden="true"></span><?php echo $this->escape($this->supportPin); ?></span>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-1" id="mokosuiteclient-btn-heartbeat-pin"
|
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-1" id="mokosuiteclient-btn-heartbeat-pin"
|
||||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.sendHeartbeat&format=json'); ?>"
|
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.sendHeartbeat&format=json'); ?>"
|
||||||
data-token="<?php echo $token; ?>"
|
data-token="<?php echo $token; ?>"
|
||||||
@@ -85,29 +79,25 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<?php if ($adminToolsAvail): ?>
|
<?php if ($adminToolsAvail || $atsAvail): ?>
|
||||||
<!-- Akeeba Import Banner -->
|
<!-- Akeeba Import Banner -->
|
||||||
<div class="alert alert-info d-flex flex-wrap align-items-center gap-3 mb-4">
|
<div class="alert alert-info d-flex flex-wrap align-items-center gap-3 mb-4">
|
||||||
<span class="icon-info-circle" style="font-size:1.25rem"></span>
|
<span class="icon-info-circle" style="font-size:1.25rem"></span>
|
||||||
<strong>Akeeba data detected:</strong>
|
<strong>Akeeba data detected — import into MokoSuiteClient:</strong>
|
||||||
|
<?php if ($adminToolsAvail): ?>
|
||||||
<button type="button" class="btn btn-sm btn-info" id="btn-import-admintools"
|
<button type="button" class="btn btn-sm btn-info" id="btn-import-admintools"
|
||||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.importAdminTools&format=json'); ?>"
|
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.importAdminTools&format=json'); ?>"
|
||||||
data-token="<?php echo $token; ?>">
|
data-token="<?php echo $token; ?>">
|
||||||
<span class="icon-shield-alt"></span> Import Admin Tools Settings
|
<span class="icon-shield-alt"></span> Import Admin Tools Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if ($atsAvail): ?>
|
||||||
<?php if ($this->regularLabsAvailable): ?>
|
<button type="button" class="btn btn-sm btn-info" id="btn-import-ats-dash"
|
||||||
<!-- Regular Labs Import Banner -->
|
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.importAts&format=json'); ?>"
|
||||||
<div class="alert alert-warning d-flex flex-wrap align-items-center gap-3 mb-4">
|
|
||||||
<span class="icon-info-circle" style="font-size:1.25rem"></span>
|
|
||||||
<strong>Regular Labs data detected — import into MokoSuite:</strong>
|
|
||||||
<button type="button" class="btn btn-sm btn-warning text-dark" id="btn-import-regularlabs"
|
|
||||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.importRegularLabs&format=json'); ?>"
|
|
||||||
data-token="<?php echo $token; ?>">
|
data-token="<?php echo $token; ?>">
|
||||||
<span class="icon-download"></span> Import Conditions, Snippets, Replacements & Templates
|
<span class="icon-headphones"></span> Import Tickets (<?php echo $atsAvail->tickets; ?> tickets)
|
||||||
</button>
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
@@ -121,14 +111,6 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
|||||||
Clear Cache
|
Clear Cache
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<?php if (\Joomla\CMS\Component\ComponentHelper::isEnabled('com_mokosuitebackup')): ?>
|
|
||||||
<div class="col-6 col-md-4 col-xl-3">
|
|
||||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup'); ?>" class="btn btn-outline-primary w-100 py-3">
|
|
||||||
<span class="icon-archive d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
|
||||||
MokoSuiteBackup
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<div class="col-6 col-md-4 col-xl-3">
|
<div class="col-6 col-md-4 col-xl-3">
|
||||||
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="btn btn-outline-primary w-100 py-3">
|
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="btn btn-outline-primary w-100 py-3">
|
||||||
<span class="icon-refresh d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
<span class="icon-refresh d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||||
@@ -213,7 +195,11 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
|||||||
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
|
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
|
||||||
<?php if ($plugin->protected): ?>
|
<?php if ($plugin->protected): ?>
|
||||||
<span class="badge bg-dark"><?php echo Text::_('COM_MOKOSUITECLIENT_PROTECTED'); ?></span>
|
<span class="badge bg-dark"><?php echo Text::_('COM_MOKOSUITECLIENT_PROTECTED'); ?></span>
|
||||||
<?php elseif ($plugin->extension_id): ?>
|
<?php elseif ($plugin->configure_only): ?>
|
||||||
|
<span class="badge bg-<?php echo $plugin->enabled ? 'success' : 'secondary'; ?>">
|
||||||
|
<?php echo $plugin->enabled ? Text::_('COM_MOKOSUITECLIENT_ENABLED') : Text::_('COM_MOKOSUITECLIENT_DISABLED'); ?>
|
||||||
|
</span>
|
||||||
|
<?php else: ?>
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input type="checkbox" class="form-check-input mokosuiteclient-toggle" role="switch"
|
<input type="checkbox" class="form-check-input mokosuiteclient-toggle" role="switch"
|
||||||
id="toggle-<?php echo $plugin->extension_id; ?>"
|
id="toggle-<?php echo $plugin->extension_id; ?>"
|
||||||
@@ -226,7 +212,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($plugin->extension_id && $plugin->type === 'plugin'): ?>
|
<?php if ($plugin->type === 'plugin'): ?>
|
||||||
<a href="<?php echo Route::_('index.php?option=com_plugins&task=plugin.edit&extension_id=' . $plugin->extension_id); ?>" class="btn btn-sm btn-outline-secondary">
|
<a href="<?php echo Route::_('index.php?option=com_plugins&task=plugin.edit&extension_id=' . $plugin->extension_id); ?>" class="btn btn-sm btn-outline-secondary">
|
||||||
<span class="icon-cog" aria-hidden="true"></span> <?php echo Text::_('COM_MOKOSUITECLIENT_CONFIGURE'); ?>
|
<span class="icon-cog" aria-hidden="true"></span> <?php echo Text::_('COM_MOKOSUITECLIENT_CONFIGURE'); ?>
|
||||||
</a>
|
</a>
|
||||||
@@ -243,7 +229,6 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
|||||||
<!-- Right: Charts & Information (4 cols) -->
|
<!-- Right: Charts & Information (4 cols) -->
|
||||||
<div class="col-12 col-xl-4" style="border-left:1px solid var(--gray-300, #dee2e6);padding-left:1.5rem;">
|
<div class="col-12 col-xl-4" style="border-left:1px solid var(--gray-300, #dee2e6);padding-left:1.5rem;">
|
||||||
|
|
||||||
<?php if ($canWafLog): ?>
|
|
||||||
<!-- WAF Activity Chart -->
|
<!-- WAF Activity Chart -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -253,7 +238,6 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
|||||||
<canvas id="mokosuiteclient-chart-waf" height="140"></canvas>
|
<canvas id="mokosuiteclient-chart-waf" height="140"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<!-- Login Activity Chart -->
|
<!-- Login Activity Chart -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
@@ -324,7 +308,6 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($canWafLog): ?>
|
|
||||||
<!-- WAF Blocks -->
|
<!-- WAF Blocks -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
@@ -352,7 +335,6 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<!-- Recent Logins -->
|
<!-- Recent Logins -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
@@ -463,5 +445,3 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<?php echo \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::renderScript(); ?>
|
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteClient
|
||||||
|
* @subpackage com_mokosuiteclient
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoSuiteClient\Api\Controller;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\Controller\BaseController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpdesk Tickets REST API controller.
|
||||||
|
*
|
||||||
|
* GET /api/index.php/v1/mokosuiteclient/tickets - list tickets
|
||||||
|
* GET /api/index.php/v1/mokosuiteclient/tickets/{id} - get single ticket with replies
|
||||||
|
* POST /api/index.php/v1/mokosuiteclient/tickets - create ticket
|
||||||
|
* PATCH /api/index.php/v1/mokosuiteclient/tickets/{id} - update ticket fields
|
||||||
|
* POST /api/index.php/v1/mokosuiteclient/tickets/{id}/reply - add reply
|
||||||
|
*
|
||||||
|
* @since 02.35.00
|
||||||
|
*/
|
||||||
|
class TicketsController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /tickets — list tickets with optional filters.
|
||||||
|
*/
|
||||||
|
public function displayList(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
||||||
|
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$input = $app->getInput();
|
||||||
|
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('t.*, s.title AS status_title, p.title AS priority_title, c.title AS category_title, u.name AS created_by_name')
|
||||||
|
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
||||||
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON s.id = t.status_id')
|
||||||
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_priorities', 'p') . ' ON p.id = t.priority_id')
|
||||||
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||||
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||||
|
->order('t.created DESC');
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
$status = $input->getString('status', '');
|
||||||
|
if ($status) {
|
||||||
|
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($status));
|
||||||
|
}
|
||||||
|
|
||||||
|
$categoryId = $input->getInt('category_id', 0);
|
||||||
|
if ($categoryId) {
|
||||||
|
$query->where($db->quoteName('t.category_id') . ' = ' . $categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignedTo = $input->getInt('assigned_to', 0);
|
||||||
|
if ($assignedTo) {
|
||||||
|
$query->where($db->quoteName('t.assigned_to') . ' = ' . $assignedTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = min($input->getInt('limit', 25), 100);
|
||||||
|
$offset = $input->getInt('offset', 0);
|
||||||
|
$db->setQuery($query, $offset, $limit);
|
||||||
|
|
||||||
|
$tickets = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
// Total count (with same filters applied)
|
||||||
|
$countQuery = clone $query;
|
||||||
|
$countQuery->clear('select')->clear('order')->select('COUNT(*)');
|
||||||
|
$db->setQuery($countQuery);
|
||||||
|
$total = (int) $db->loadResult();
|
||||||
|
|
||||||
|
$this->sendJson(200, [
|
||||||
|
'tickets' => $tickets,
|
||||||
|
'total' => $total,
|
||||||
|
'limit' => $limit,
|
||||||
|
'offset' => $offset,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /tickets/{id} — single ticket with replies and attachments.
|
||||||
|
*/
|
||||||
|
public function displayItem(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
||||||
|
|
||||||
|
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
|
// Ticket
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('t.*, s.title AS status_title, p.title AS priority_title, c.title AS category_title, u.name AS created_by_name')
|
||||||
|
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
||||||
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON s.id = t.status_id')
|
||||||
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_priorities', 'p') . ' ON p.id = t.priority_id')
|
||||||
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||||
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||||
|
->where('t.id = ' . $id)
|
||||||
|
);
|
||||||
|
$ticket = $db->loadObject();
|
||||||
|
|
||||||
|
if (!$ticket) {
|
||||||
|
$this->sendJson(404, ['error' => 'Ticket not found']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replies
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('r.*, u.name AS user_name')
|
||||||
|
->from($db->quoteName('#__mokosuiteclient_ticket_replies', 'r'))
|
||||||
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
|
||||||
|
->where('r.ticket_id = ' . $id)
|
||||||
|
->order('r.created ASC')
|
||||||
|
);
|
||||||
|
$ticket->replies = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
$ticket->attachments = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getForTicket($id);
|
||||||
|
|
||||||
|
$this->sendJson(200, $ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /tickets — create a new ticket.
|
||||||
|
*/
|
||||||
|
public function create(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
||||||
|
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
|
$subject = $input->getString('subject', '');
|
||||||
|
$body = $input->getRaw('body', '');
|
||||||
|
|
||||||
|
if (empty($subject)) {
|
||||||
|
$this->sendJson(400, ['error' => 'Subject is required']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statusId = $input->getInt('status_id', 0) ?: null;
|
||||||
|
$priorityId = $input->getInt('priority_id', 0) ?: null;
|
||||||
|
$status = $input->getString('status', 'open');
|
||||||
|
$priority = $input->getString('priority', 'normal');
|
||||||
|
|
||||||
|
// Resolve status_id from alias if not provided
|
||||||
|
if (!$statusId && $status) {
|
||||||
|
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
|
||||||
|
->where($db->quoteName('alias') . ' = ' . $db->quote($status));
|
||||||
|
$statusId = (int) $db->setQuery($q, 0, 1)->loadResult() ?: null;
|
||||||
|
}
|
||||||
|
if (!$priorityId && $priority) {
|
||||||
|
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_priorities')
|
||||||
|
->where($db->quoteName('alias') . ' = ' . $db->quote($priority));
|
||||||
|
$priorityId = (int) $db->setQuery($q, 0, 1)->loadResult() ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket = (object) [
|
||||||
|
'subject' => $subject,
|
||||||
|
'body' => $body,
|
||||||
|
'status' => $status,
|
||||||
|
'status_id' => $statusId,
|
||||||
|
'priority' => $priority,
|
||||||
|
'priority_id' => $priorityId,
|
||||||
|
'category_id' => $input->getInt('category_id', 0) ?: null,
|
||||||
|
'created_by' => (int) Factory::getUser()->id,
|
||||||
|
'assigned_to' => $input->getInt('assigned_to', 0) ?: null,
|
||||||
|
'created' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id');
|
||||||
|
|
||||||
|
// Trigger notification
|
||||||
|
\Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::notify('ticket_created', $ticket);
|
||||||
|
|
||||||
|
$this->sendJson(201, ['id' => (int) $ticket->id, 'message' => 'Ticket created']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /tickets/{id} — update ticket fields.
|
||||||
|
*/
|
||||||
|
public function update(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
||||||
|
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
$id = $input->getInt('id', 0);
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
|
// Type-safe input extraction
|
||||||
|
$fields = [];
|
||||||
|
$intFields = ['status_id', 'priority_id', 'category_id', 'assigned_to'];
|
||||||
|
$strFields = ['status', 'priority'];
|
||||||
|
|
||||||
|
foreach ($intFields as $field) {
|
||||||
|
$value = $input->getInt($field, 0);
|
||||||
|
if ($value > 0) { $fields[$field] = $value; }
|
||||||
|
}
|
||||||
|
foreach ($strFields as $field) {
|
||||||
|
$value = $input->getString($field, '');
|
||||||
|
if ($value !== '') { $fields[$field] = $value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($fields)) {
|
||||||
|
$this->sendJson(400, ['error' => 'No fields to update']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync status/status_id if only one is provided
|
||||||
|
if (isset($fields['status']) && !isset($fields['status_id'])) {
|
||||||
|
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
|
||||||
|
->where($db->quoteName('alias') . ' = ' . $db->quote($fields['status']));
|
||||||
|
$resolved = (int) $db->setQuery($q, 0, 1)->loadResult();
|
||||||
|
if ($resolved) { $fields['status_id'] = $resolved; }
|
||||||
|
} elseif (isset($fields['status_id']) && !isset($fields['status'])) {
|
||||||
|
$q = $db->getQuery(true)->select('alias')->from('#__mokosuiteclient_ticket_statuses')
|
||||||
|
->where('id = ' . (int) $fields['status_id']);
|
||||||
|
$alias = $db->setQuery($q, 0, 1)->loadResult();
|
||||||
|
if ($alias) { $fields['status'] = $alias; }
|
||||||
|
}
|
||||||
|
if (isset($fields['priority']) && !isset($fields['priority_id'])) {
|
||||||
|
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_priorities')
|
||||||
|
->where($db->quoteName('alias') . ' = ' . $db->quote($fields['priority']));
|
||||||
|
$resolved = (int) $db->setQuery($q, 0, 1)->loadResult();
|
||||||
|
if ($resolved) { $fields['priority_id'] = $resolved; }
|
||||||
|
} elseif (isset($fields['priority_id']) && !isset($fields['priority'])) {
|
||||||
|
$q = $db->getQuery(true)->select('alias')->from('#__mokosuiteclient_ticket_priorities')
|
||||||
|
->where('id = ' . (int) $fields['priority_id']);
|
||||||
|
$alias = $db->setQuery($q, 0, 1)->loadResult();
|
||||||
|
if ($alias) { $fields['priority'] = $alias; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$sets = [];
|
||||||
|
foreach ($fields as $k => $v) {
|
||||||
|
$sets[] = $db->quoteName($k) . ' = ' . (is_int($v) ? $v : $db->quote($v));
|
||||||
|
}
|
||||||
|
$sets[] = 'modified = ' . $db->quote(Factory::getDate()->toSql());
|
||||||
|
|
||||||
|
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_tickets') . ' SET ' . implode(', ', $sets) . ' WHERE id = ' . $id)->execute();
|
||||||
|
|
||||||
|
if ($db->getAffectedRows() === 0) {
|
||||||
|
$this->sendJson(404, ['error' => 'Ticket not found']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendJson(200, ['id' => $id, 'message' => 'Ticket updated', 'updated' => array_keys($fields)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /tickets/{id}/reply — add a reply.
|
||||||
|
*/
|
||||||
|
public function reply(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
||||||
|
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
$ticketId = $input->getInt('id', 0);
|
||||||
|
$body = $input->getRaw('body', '');
|
||||||
|
|
||||||
|
if (!$ticketId || empty($body)) {
|
||||||
|
$this->sendJson(400, ['error' => 'ticket_id and body are required']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
|
$reply = (object) [
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'user_id' => (int) Factory::getUser()->id,
|
||||||
|
'body' => $body,
|
||||||
|
'is_internal' => $input->getInt('is_internal', 0),
|
||||||
|
'created' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply, 'id');
|
||||||
|
|
||||||
|
// Notify
|
||||||
|
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_tickets')->where('id = ' . $ticketId));
|
||||||
|
$ticket = $db->loadObject();
|
||||||
|
if ($ticket) {
|
||||||
|
\Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendJson(201, ['reply_id' => (int) $reply->id, 'message' => 'Reply added']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function requireAuth(string $action, string $asset): void
|
||||||
|
{
|
||||||
|
$user = Factory::getUser();
|
||||||
|
if (!$user->authorise($action, $asset)) {
|
||||||
|
$this->sendJson(403, ['error' => 'Not authorized']);
|
||||||
|
throw new \RuntimeException('Not authorized', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendJson(int $code, $payload): void
|
||||||
|
{
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$app->setHeader('Content-Type', 'application/json', true);
|
||||||
|
$app->setHeader('Status', (string) $code, true);
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
$app->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,37 +144,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular Labs import
|
|
||||||
var rlBtn = document.getElementById('btn-import-regularlabs');
|
|
||||||
if (rlBtn) {
|
|
||||||
rlBtn.addEventListener('click', function () {
|
|
||||||
var btn = this;
|
|
||||||
if (!confirm('Import Regular Labs data (conditions, snippets, replacements, templates) into MokoSuite?')) return;
|
|
||||||
btn.disabled = true;
|
|
||||||
var origText = btn.textContent;
|
|
||||||
btn.textContent = ' Importing...';
|
|
||||||
var fd = new FormData();
|
|
||||||
fd.append(btn.dataset.token, '1');
|
|
||||||
fetch(btn.dataset.url, {method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}})
|
|
||||||
.then(function (r) { return r.json(); })
|
|
||||||
.then(function (d) {
|
|
||||||
if (d.success) {
|
|
||||||
Joomla.renderMessages({message: [d.message]});
|
|
||||||
setTimeout(function () { location.reload(); }, 2000);
|
|
||||||
} else {
|
|
||||||
Joomla.renderMessages({error: [d.message]});
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = origText;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function () {
|
|
||||||
Joomla.renderMessages({error: ['Network error']});
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = origText;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akeeba import buttons
|
// Akeeba import buttons
|
||||||
['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) {
|
['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) {
|
||||||
var btn = document.getElementById(id);
|
var btn = document.getElementById(id);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.48.24</version>
|
<version>02.47.48</version>
|
||||||
<description>MokoSuiteClient admin dashboard and REST API. Provides a control panel for managing MokoSuiteClient feature plugins, site health monitoring, and remote management endpoints.</description>
|
<description>MokoSuiteClient admin dashboard and REST API. Provides a control panel for managing MokoSuiteClient feature plugins, site health monitoring, and remote management endpoints.</description>
|
||||||
|
|
||||||
<namespace path="src">Moko\Component\MokoSuiteClient</namespace>
|
<namespace path="src">Moko\Component\MokoSuiteClient</namespace>
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
<submenu>
|
<submenu>
|
||||||
<menu link="option=com_mokosuiteclient" img="class:cogs">COM_MOKOSUITECLIENT_MENU_DASHBOARD</menu>
|
<menu link="option=com_mokosuiteclient" img="class:cogs">COM_MOKOSUITECLIENT_MENU_DASHBOARD</menu>
|
||||||
<menu link="option=com_mokosuiteclient&view=extensions" img="class:puzzle-piece">COM_MOKOSUITECLIENT_MENU_EXTENSIONS</menu>
|
<menu link="option=com_mokosuiteclient&view=extensions" img="class:puzzle-piece">COM_MOKOSUITECLIENT_MENU_EXTENSIONS</menu>
|
||||||
|
<menu link="option=com_mokosuiteclient&view=tickets" img="class:headphones">COM_MOKOSUITECLIENT_MENU_TICKETS</menu>
|
||||||
<menu link="option=com_mokosuiteclient&view=htaccess" img="class:file-code">COM_MOKOSUITECLIENT_MENU_HTACCESS</menu>
|
<menu link="option=com_mokosuiteclient&view=htaccess" img="class:file-code">COM_MOKOSUITECLIENT_MENU_HTACCESS</menu>
|
||||||
<menu link="option=com_mokosuiteclient&view=privacy" img="class:lock">COM_MOKOSUITECLIENT_MENU_PRIVACY</menu>
|
<menu link="option=com_mokosuiteclient&view=privacy" img="class:lock">COM_MOKOSUITECLIENT_MENU_PRIVACY</menu>
|
||||||
<menu link="option=com_mokosuiteclient&view=waflog" img="class:shield-alt">COM_MOKOSUITECLIENT_MENU_WAFLOG</menu>
|
<menu link="option=com_mokosuiteclient&view=waflog" img="class:shield-alt">COM_MOKOSUITECLIENT_MENU_WAFLOG</menu>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteClient
|
||||||
|
* @subpackage com_mokosuiteclient.site
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoSuiteClient\Site\View\Ticket;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
|
||||||
|
class HtmlView extends BaseHtmlView
|
||||||
|
{
|
||||||
|
protected $ticket;
|
||||||
|
protected $isStaff = false;
|
||||||
|
protected $canAssign = false;
|
||||||
|
|
||||||
|
public function display($tpl = null)
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||||
|
$user = Factory::getApplication()->getIdentity();
|
||||||
|
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||||
|
|
||||||
|
$this->isStaff = $user->authorise('core.admin') || $user->authorise('mokosuiteclient.tickets', 'com_mokosuiteclient');
|
||||||
|
$this->canAssign = $user->authorise('core.admin') || $user->authorise('mokosuiteclient.tickets.assign', 'com_mokosuiteclient');
|
||||||
|
|
||||||
|
// Get ticket — staff see any, customers see only their own
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select([
|
||||||
|
$db->quoteName('t') . '.*',
|
||||||
|
$db->quoteName('c.title', 'category_title'),
|
||||||
|
$db->quoteName('u.name', 'created_by_name'),
|
||||||
|
$db->quoteName('u.email', 'created_by_email'),
|
||||||
|
$db->quoteName('a.name', 'assigned_to_name'),
|
||||||
|
])
|
||||||
|
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
||||||
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||||
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||||
|
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to')
|
||||||
|
->where($db->quoteName('t.id') . ' = ' . $id);
|
||||||
|
|
||||||
|
if (!$this->isStaff)
|
||||||
|
{
|
||||||
|
$query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->setQuery($query);
|
||||||
|
$this->ticket = $db->loadObject();
|
||||||
|
|
||||||
|
if (!$this->ticket)
|
||||||
|
{
|
||||||
|
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
|
||||||
|
Factory::getApplication()->redirect(Route::_('index.php?option=com_mokosuiteclient&view=tickets', false));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load replies — staff see internal notes, customers don't
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select([
|
||||||
|
$db->quoteName('r') . '.*',
|
||||||
|
$db->quoteName('u.name', 'user_name'),
|
||||||
|
])
|
||||||
|
->from($db->quoteName('#__mokosuiteclient_ticket_replies', 'r'))
|
||||||
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
|
||||||
|
->where($db->quoteName('r.ticket_id') . ' = ' . $id);
|
||||||
|
|
||||||
|
if (!$this->isStaff)
|
||||||
|
{
|
||||||
|
$query->where($db->quoteName('r.is_internal') . ' = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->order($db->quoteName('r.created') . ' ASC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
$this->ticket->replies = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
parent::display($tpl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoSuiteClient
|
||||||
|
* @subpackage com_mokosuiteclient.site
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoSuiteClient\Site\View\Tickets;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
|
|
||||||
|
class HtmlView extends BaseHtmlView
|
||||||
|
{
|
||||||
|
protected $tickets = [];
|
||||||
|
protected $categories = [];
|
||||||
|
protected $isStaff = false;
|
||||||
|
|
||||||
|
public function display($tpl = null)
|
||||||
|
{
|
||||||
|
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||||
|
$user = Factory::getApplication()->getIdentity();
|
||||||
|
|
||||||
|
$this->isStaff = $user->authorise('core.admin')
|
||||||
|
|| $user->authorise('mokosuiteclient.tickets', 'com_mokosuiteclient');
|
||||||
|
|
||||||
|
// Staff see all tickets, customers see their own
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select([
|
||||||
|
$db->quoteName('t.id'),
|
||||||
|
$db->quoteName('t.subject'),
|
||||||
|
$db->quoteName('t.status'),
|
||||||
|
$db->quoteName('t.priority'),
|
||||||
|
$db->quoteName('t.created'),
|
||||||
|
$db->quoteName('t.assigned_to'),
|
||||||
|
$db->quoteName('c.title', 'category_title'),
|
||||||
|
$db->quoteName('u.name', 'created_by_name'),
|
||||||
|
$db->quoteName('a.name', 'assigned_to_name'),
|
||||||
|
])
|
||||||
|
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
||||||
|
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||||
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||||
|
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to');
|
||||||
|
|
||||||
|
if (!$this->isStaff)
|
||||||
|
{
|
||||||
|
$query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filterStatus = Factory::getApplication()->getInput()->getString('filter_status', '');
|
||||||
|
|
||||||
|
if ($filterStatus)
|
||||||
|
{
|
||||||
|
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($filterStatus));
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->order($db->quoteName('t.created') . ' DESC')->setLimit(50);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$this->tickets = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
// Categories for new ticket form
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select([$db->quoteName('id'), $db->quoteName('title')])
|
||||||
|
->from($db->quoteName('#__mokosuiteclient_ticket_categories'))
|
||||||
|
->where($db->quoteName('published') . ' = 1')
|
||||||
|
->order($db->quoteName('ordering') . ' ASC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
$this->categories = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
parent::display($tpl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<?php
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\HTML\HTMLHelper;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
|
||||||
|
$t = $this->ticket;
|
||||||
|
$isStaff = $this->isStaff;
|
||||||
|
$canAssign = $this->canAssign;
|
||||||
|
$token = Session::getFormToken();
|
||||||
|
$userId = Factory::getApplication()->getIdentity()->id;
|
||||||
|
|
||||||
|
$statusLabel = [
|
||||||
|
'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response',
|
||||||
|
'resolved' => 'Resolved', 'closed' => 'Closed',
|
||||||
|
];
|
||||||
|
$statusClass = [
|
||||||
|
'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning',
|
||||||
|
'resolved' => 'success', 'closed' => 'secondary',
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="mokosuiteclient-portal-ticket">
|
||||||
|
<div class="mb-3">
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=tickets'); ?>" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<span class="icon-arrow-left"></span> Back to Tickets
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Main column: conversation -->
|
||||||
|
<div class="col-12 <?php echo $isStaff ? 'col-lg-8' : ''; ?>">
|
||||||
|
|
||||||
|
<!-- Ticket header -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-1">#<?php echo $t->id; ?> — <?php echo htmlspecialchars($t->subject); ?></h3>
|
||||||
|
<small class="text-muted">
|
||||||
|
<?php echo htmlspecialchars($t->category_title ?? 'General'); ?>
|
||||||
|
· <?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?>
|
||||||
|
· <?php echo ucfirst($t->priority); ?>
|
||||||
|
<?php if ($isStaff): ?>
|
||||||
|
· By: <?php echo htmlspecialchars($t->created_by_name); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?> fs-6">
|
||||||
|
<?php echo $statusLabel[$t->status] ?? $t->status; ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Original message -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<strong><?php echo htmlspecialchars($t->created_by_name); ?></strong>
|
||||||
|
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body"><?php echo nl2br(htmlspecialchars($t->body)); ?></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Replies -->
|
||||||
|
<?php foreach ($t->replies as $reply): ?>
|
||||||
|
<?php
|
||||||
|
$replyIsStaffUser = ((int) $reply->user_id !== (int) $t->created_by);
|
||||||
|
$isInternal = (int) $reply->is_internal;
|
||||||
|
?>
|
||||||
|
<div class="card mb-3 <?php echo $isInternal ? 'border-warning bg-warning bg-opacity-10' : ($replyIsStaffUser ? 'border-primary' : ''); ?>">
|
||||||
|
<div class="card-header d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<strong><?php echo htmlspecialchars($reply->user_name ?? 'Support'); ?></strong>
|
||||||
|
<?php if ($replyIsStaffUser): ?><span class="badge bg-primary ms-1">Staff</span><?php endif; ?>
|
||||||
|
<?php if ($isInternal): ?><span class="badge bg-warning text-dark ms-1">Internal Note</span><?php endif; ?>
|
||||||
|
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $reply->created, 'M d, Y H:i'); ?></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body"><?php echo nl2br(htmlspecialchars($reply->body)); ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<!-- Reply form -->
|
||||||
|
<?php if (!\in_array($t->status, ['closed'])): ?>
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Reply</h5>
|
||||||
|
<form id="portalReply">
|
||||||
|
<textarea name="body" class="form-control mb-3" rows="5" required placeholder="Type your reply..."></textarea>
|
||||||
|
<input type="hidden" name="ticket_id" value="<?php echo $t->id; ?>">
|
||||||
|
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<span class="icon-paper-plane"></span> Send Reply
|
||||||
|
</button>
|
||||||
|
<?php if ($isStaff): ?>
|
||||||
|
<button type="button" class="btn btn-outline-warning" id="btn-internal-note">
|
||||||
|
<span class="icon-eye-slash"></span> Internal Note
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($t->status === 'closed'): ?>
|
||||||
|
<div class="alert alert-secondary mt-4">
|
||||||
|
This ticket is closed. <a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=tickets&layout=submit'); ?>">Open a new ticket</a> if you need further help.
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Staff sidebar -->
|
||||||
|
<?php if ($isStaff): ?>
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<!-- Ticket info -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><strong>Details</strong></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-5 text-muted">Status</dt>
|
||||||
|
<dd class="col-7"><span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$t->status] ?? $t->status; ?></span></dd>
|
||||||
|
<dt class="col-5 text-muted">Priority</dt>
|
||||||
|
<dd class="col-7"><?php echo ucfirst($t->priority); ?></dd>
|
||||||
|
<dt class="col-5 text-muted">Category</dt>
|
||||||
|
<dd class="col-7"><?php echo htmlspecialchars($t->category_title ?? '—'); ?></dd>
|
||||||
|
<dt class="col-5 text-muted">Submitted By</dt>
|
||||||
|
<dd class="col-7"><?php echo htmlspecialchars($t->created_by_name); ?><br><small class="text-muted"><?php echo htmlspecialchars($t->created_by_email ?? ''); ?></small></dd>
|
||||||
|
<dt class="col-5 text-muted">Assigned To</dt>
|
||||||
|
<dd class="col-7"><?php echo htmlspecialchars($t->assigned_to_name ?? 'Unassigned'); ?></dd>
|
||||||
|
<dt class="col-5 text-muted">Created</dt>
|
||||||
|
<dd class="col-7"><?php echo HTMLHelper::_('date', $t->created, 'M d H:i'); ?></dd>
|
||||||
|
<dt class="col-5 text-muted">Replies</dt>
|
||||||
|
<dd class="col-7"><?php echo \count($t->replies); ?></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status actions -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><strong>Change Status</strong></div>
|
||||||
|
<div class="card-body d-grid gap-2">
|
||||||
|
<?php foreach (['open' => 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting on Customer', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?>
|
||||||
|
<?php if ($s !== $t->status): ?>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-<?php echo $s === 'closed' ? 'danger' : ($s === 'resolved' ? 'success' : 'secondary'); ?> btn-status"
|
||||||
|
data-status="<?php echo $s; ?>">
|
||||||
|
<?php echo $label; ?>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($canAssign): ?>
|
||||||
|
<!-- Quick assign -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><strong>Assign</strong></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary w-100" id="btn-assign-me">
|
||||||
|
Assign to Me
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var token = '<?php echo $token; ?>';
|
||||||
|
var ticketId = <?php echo $t->id; ?>;
|
||||||
|
|
||||||
|
// Reply
|
||||||
|
var replyForm = document.getElementById('portalReply');
|
||||||
|
if (replyForm) {
|
||||||
|
replyForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendReply(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal note
|
||||||
|
var internalBtn = document.getElementById('btn-internal-note');
|
||||||
|
if (internalBtn) {
|
||||||
|
internalBtn.addEventListener('click', function() { sendReply(true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendReply(isInternal) {
|
||||||
|
var body = replyForm.querySelector('textarea[name=body]').value.trim();
|
||||||
|
if (!body) return;
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('ticket_id', ticketId);
|
||||||
|
fd.append('body', body);
|
||||||
|
fd.append('is_internal', isInternal ? '1' : '0');
|
||||||
|
fd.append(token, '1');
|
||||||
|
fetch('<?php echo Route::_("index.php?option=com_mokosuiteclient&task=display.submitReply&format=json"); ?>', {
|
||||||
|
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
|
||||||
|
}).then(function(r){return r.json()}).then(function(d){
|
||||||
|
if (d.success) location.reload();
|
||||||
|
else alert(d.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status buttons
|
||||||
|
document.querySelectorAll('.btn-status').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('ticket_id', ticketId);
|
||||||
|
fd.append('status', this.dataset.status);
|
||||||
|
fd.append(token, '1');
|
||||||
|
fetch('<?php echo Route::_("index.php?option=com_mokosuiteclient&task=display.updateStatus&format=json"); ?>', {
|
||||||
|
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
|
||||||
|
}).then(function(r){return r.json()}).then(function(d){
|
||||||
|
if (d.success) location.reload();
|
||||||
|
else alert(d.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign to me
|
||||||
|
var assignBtn = document.getElementById('btn-assign-me');
|
||||||
|
if (assignBtn) {
|
||||||
|
assignBtn.addEventListener('click', function() {
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('ticket_id', ticketId);
|
||||||
|
fd.append('assigned_to', <?php echo $userId; ?>);
|
||||||
|
fd.append(token, '1');
|
||||||
|
fetch('<?php echo Route::_("index.php?option=com_mokosuiteclient&task=display.assignTicket&format=json"); ?>', {
|
||||||
|
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
|
||||||
|
}).then(function(r){return r.json()}).then(function(d){
|
||||||
|
if (d.success) location.reload();
|
||||||
|
else alert(d.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\HTML\HTMLHelper;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
|
|
||||||
|
$tickets = $this->tickets;
|
||||||
|
$categories = $this->categories;
|
||||||
|
$isStaff = $this->isStaff;
|
||||||
|
$token = Session::getFormToken();
|
||||||
|
|
||||||
|
$statusLabel = [
|
||||||
|
'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response',
|
||||||
|
'resolved' => 'Resolved', 'closed' => 'Closed',
|
||||||
|
];
|
||||||
|
$statusClass = [
|
||||||
|
'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning',
|
||||||
|
'resolved' => 'success', 'closed' => 'secondary',
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="mokosuiteclient-portal">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2><?php echo $isStaff ? 'All Support Tickets' : 'My Support Tickets'; ?></h2>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=tickets&layout=submit'); ?>" class="btn btn-primary">
|
||||||
|
<span class="icon-plus"></span> New Ticket
|
||||||
|
</a>
|
||||||
|
<?php if ($isStaff): ?>
|
||||||
|
<form method="get" class="d-inline">
|
||||||
|
<input type="hidden" name="option" value="com_mokosuiteclient">
|
||||||
|
<input type="hidden" name="view" value="tickets">
|
||||||
|
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<?php foreach ($statusLabel as $k => $v): ?>
|
||||||
|
<option value="<?php echo $k; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $k ? 'selected' : ''; ?>><?php echo $v; ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (empty($tickets)): ?>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<span class="icon-info-circle"></span>
|
||||||
|
<?php echo $isStaff ? 'No tickets found.' : 'You haven\'t submitted any support tickets yet.'; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<?php if ($isStaff): ?><th>Submitted By</th><th>Assigned To</th><?php endif; ?>
|
||||||
|
<th>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($tickets as $t): ?>
|
||||||
|
<tr>
|
||||||
|
<td><a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
|
||||||
|
<td><a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&view=ticket&id=' . $t->id); ?>"><?php echo htmlspecialchars(mb_substr($t->subject, 0, 60)); ?></a></td>
|
||||||
|
<td><span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$t->status] ?? $t->status; ?></span></td>
|
||||||
|
<td><?php echo ucfirst($t->priority); ?></td>
|
||||||
|
<td><?php echo htmlspecialchars($t->category_title ?? '—'); ?></td>
|
||||||
|
<?php if ($isStaff): ?>
|
||||||
|
<td><?php echo htmlspecialchars($t->created_by_name ?? ''); ?></td>
|
||||||
|
<td><?php echo htmlspecialchars($t->assigned_to_name ?? '<em>Unassigned</em>'); ?></td>
|
||||||
|
<?php endif; ?>
|
||||||
|
<td class="text-nowrap"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y'); ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Submit a Ticket layout — search KB first, then submit form.
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
|
|
||||||
|
$categories = $this->categories;
|
||||||
|
$token = Session::getFormToken();
|
||||||
|
$searchUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.searchKb&format=json');
|
||||||
|
$submitUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.submitTicket&format=json');
|
||||||
|
$ticketUrl = Route::_('index.php?option=com_mokosuiteclient&view=ticket&id=');
|
||||||
|
$ticketsUrl = Route::_('index.php?option=com_mokosuiteclient&view=tickets');
|
||||||
|
|
||||||
|
// Check if Smart Search has indexed content
|
||||||
|
$finderEnabled = false;
|
||||||
|
try {
|
||||||
|
$db = \Joomla\CMS\Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||||
|
$db->setQuery('SELECT COUNT(*) FROM #__finder_links WHERE published = 1');
|
||||||
|
$finderEnabled = (int) $db->loadResult() > 0;
|
||||||
|
} catch (\Throwable $e) {}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="mokosuiteclient-portal">
|
||||||
|
<h2>Submit a Support Request</h2>
|
||||||
|
|
||||||
|
<?php if ($finderEnabled): ?>
|
||||||
|
<!-- Step 1: Search -->
|
||||||
|
<div id="step-search" class="mb-4">
|
||||||
|
<p class="text-muted">Before submitting, let's see if we already have an answer for you.</p>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<label class="form-label fw-bold" for="kb-search">Describe your issue</label>
|
||||||
|
<div class="input-group input-group-lg">
|
||||||
|
<input type="text" id="kb-search" class="form-control" placeholder="e.g. how do I reset my password?" autofocus>
|
||||||
|
<button type="button" class="btn btn-primary" id="kb-search-btn">
|
||||||
|
<span class="icon-search"></span> Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search results -->
|
||||||
|
<div id="kb-results" class="mt-3 d-none">
|
||||||
|
<h5>Related Articles</h5>
|
||||||
|
<div id="kb-results-list" class="list-group mb-3"></div>
|
||||||
|
<p class="text-muted">Didn't find what you need?</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<button type="button" class="btn btn-outline-primary" id="btn-show-form">
|
||||||
|
<span class="icon-plus"></span> Submit a Ticket Anyway
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Step 2: Ticket Form -->
|
||||||
|
<div id="step-form" class="<?php echo $finderEnabled ? 'd-none' : ''; ?>">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title mb-3">Ticket Details</h5>
|
||||||
|
<form id="submitTicketForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="ticket-subject">Subject <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" id="ticket-subject" name="subject" class="form-control" required placeholder="Brief description of your issue">
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="ticket-category">Category</label>
|
||||||
|
<select id="ticket-category" name="category_id" class="form-select">
|
||||||
|
<option value="">Select a category</option>
|
||||||
|
<?php foreach ($categories as $cat): ?>
|
||||||
|
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="ticket-priority">Priority</label>
|
||||||
|
<select id="ticket-priority" name="priority" class="form-select">
|
||||||
|
<option value="normal">Normal</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="urgent">Urgent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="ticket-body">Description <span class="text-danger">*</span></label>
|
||||||
|
<textarea id="ticket-body" name="body" class="form-control" rows="8" required placeholder="Please describe your issue in detail."></textarea>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<span class="icon-paper-plane"></span> Submit Ticket
|
||||||
|
</button>
|
||||||
|
<a href="<?php echo $ticketsUrl; ?>" class="btn btn-outline-secondary btn-lg">
|
||||||
|
My Tickets
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var searchInput = document.getElementById('kb-search');
|
||||||
|
var searchBtn = document.getElementById('kb-search-btn');
|
||||||
|
var resultBox = document.getElementById('kb-results');
|
||||||
|
var resultList = document.getElementById('kb-results-list');
|
||||||
|
var showFormBtn = document.getElementById('btn-show-form');
|
||||||
|
var stepSearch = document.getElementById('step-search');
|
||||||
|
var stepForm = document.getElementById('step-form');
|
||||||
|
var subjectField = document.getElementById('ticket-subject');
|
||||||
|
|
||||||
|
// Search
|
||||||
|
function doSearch() {
|
||||||
|
var q = (searchInput ? searchInput.value.trim() : '');
|
||||||
|
if (q.length < 3) return;
|
||||||
|
|
||||||
|
fetch('<?php echo $searchUrl; ?>&q=' + encodeURIComponent(q), {
|
||||||
|
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
resultList.textContent = '';
|
||||||
|
if (d.results && d.results.length > 0) {
|
||||||
|
d.results.forEach(function(item) {
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = item.url;
|
||||||
|
a.target = '_blank';
|
||||||
|
a.className = 'list-group-item list-group-item-action';
|
||||||
|
var strong = document.createElement('strong');
|
||||||
|
strong.textContent = item.title;
|
||||||
|
a.appendChild(strong);
|
||||||
|
if (item.description) {
|
||||||
|
a.appendChild(document.createElement('br'));
|
||||||
|
var small = document.createElement('small');
|
||||||
|
small.className = 'text-muted';
|
||||||
|
small.textContent = item.description;
|
||||||
|
a.appendChild(small);
|
||||||
|
}
|
||||||
|
resultList.appendChild(a);
|
||||||
|
});
|
||||||
|
resultBox.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
resultBox.classList.add('d-none');
|
||||||
|
}
|
||||||
|
// Always show the "submit anyway" button after search
|
||||||
|
if (showFormBtn) showFormBtn.classList.remove('d-none');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchBtn) searchBtn.addEventListener('click', doSearch);
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); doSearch(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show form and prefill subject from search query
|
||||||
|
if (showFormBtn) {
|
||||||
|
showFormBtn.addEventListener('click', function() {
|
||||||
|
if (stepSearch) stepSearch.classList.add('d-none');
|
||||||
|
if (stepForm) stepForm.classList.remove('d-none');
|
||||||
|
if (searchInput && subjectField && !subjectField.value) {
|
||||||
|
subjectField.value = searchInput.value;
|
||||||
|
}
|
||||||
|
subjectField.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit ticket
|
||||||
|
var form = document.getElementById('submitTicketForm');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var btn = form.querySelector('button[type=submit]');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = ' Submitting...';
|
||||||
|
var fd = new FormData(form);
|
||||||
|
fetch('<?php echo $submitUrl; ?>', {
|
||||||
|
method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (d.success && d.id) {
|
||||||
|
window.location.href = '<?php echo $ticketUrl; ?>' + d.id;
|
||||||
|
} else {
|
||||||
|
alert(d.message || 'Failed.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = ' Submit Ticket';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { alert('Network error.'); btn.disabled = false; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.48.24</version>
|
<version>02.47.48</version>
|
||||||
<description>MOD_MOKOSUITECLIENT_CACHE_DESC</description>
|
<description>MOD_MOKOSUITECLIENT_CACHE_DESC</description>
|
||||||
<namespace path="src">Moko\Module\MokoSuiteClientCache</namespace>
|
<namespace path="src">Moko\Module\MokoSuiteClientCache</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -4,22 +4,14 @@ namespace Moko\Module\MokoSuiteClientCache\Administrator\Dispatcher;
|
|||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
|
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\Uri\Uri;
|
use Joomla\CMS\Uri\Uri;
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
|
|
||||||
class Dispatcher extends AbstractModuleDispatcher
|
class Dispatcher extends AbstractModuleDispatcher
|
||||||
{
|
{
|
||||||
protected function getLayoutData()
|
protected function getLayoutData()
|
||||||
{
|
{
|
||||||
$data = parent::getLayoutData();
|
$data = parent::getLayoutData();
|
||||||
|
$data['domain'] = parse_url(Uri::root(), PHP_URL_HOST) ?: '';
|
||||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
|
||||||
$pinState = \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::getState($db);
|
|
||||||
|
|
||||||
$data['supportPinAvailable'] = $pinState['available'];
|
|
||||||
$data['supportPin'] = $pinState['pin'];
|
|
||||||
$data['frontendUrl'] = rtrim(Uri::root(), '/');
|
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
/**
|
/**
|
||||||
* MokoSuiteClient Cache & Temp Cleaner — status bar split button
|
* MokoSuiteClient Cache & Temp Cleaner — status bar split button
|
||||||
*
|
*
|
||||||
* 4 buttons: Frontend link | Support PIN | Clear Cache | Clear Temp
|
* Displays "Clear: Cache | Temp" as a single header item with two
|
||||||
|
* clickable halves. Uses native Atum header-item markup.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
@@ -12,35 +13,28 @@ use Joomla\CMS\Session\Session;
|
|||||||
$token = Session::getFormToken();
|
$token = Session::getFormToken();
|
||||||
$cacheUrl = 'index.php?option=com_mokosuiteclient&task=display.clearCache&format=json';
|
$cacheUrl = 'index.php?option=com_mokosuiteclient&task=display.clearCache&format=json';
|
||||||
$tempUrl = 'index.php?option=com_mokosuiteclient&task=display.clearTemp&format=json';
|
$tempUrl = 'index.php?option=com_mokosuiteclient&task=display.clearTemp&format=json';
|
||||||
$pinAvailable = $supportPinAvailable ?? false;
|
$domain = $domain ?? '';
|
||||||
$pin = $supportPin ?? '';
|
|
||||||
$frontendUrl = $frontendUrl ?? '';
|
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
<?php if ($domain): ?>
|
||||||
<div class="header-item">
|
<div class="header-item">
|
||||||
<div class="header-item-content d-flex align-items-center gap-0" style="padding:0;">
|
<span class="header-item-content" id="mokosuiteclient-domain" title="Support key — click to copy" style="cursor:pointer;">
|
||||||
<?php if ($frontendUrl): ?>
|
<div class="header-item-icon"><span class="icon-key" aria-hidden="true"></span></div>
|
||||||
<a href="<?php echo htmlspecialchars($frontendUrl); ?>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-success rounded-0 rounded-start border-end-0 d-flex align-items-center gap-1 px-3 py-2" title="Open frontend" style="font-size:0.8rem;">
|
<div class="header-item-text"><?php echo htmlspecialchars($domain); ?></div>
|
||||||
<span class="icon-external-link-alt" aria-hidden="true"></span> Site
|
</span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="header-item">
|
||||||
|
<a href="#" class="header-item-content" id="mokosuiteclient-clear-cache" title="Clear all Joomla cache">
|
||||||
|
<div class="header-item-icon"><span class="icon-bolt" aria-hidden="true" id="mokosuiteclient-cache-icon"></span></div>
|
||||||
|
<div class="header-item-text">Cache</div>
|
||||||
</a>
|
</a>
|
||||||
<?php endif; ?>
|
</div>
|
||||||
<?php if ($pinAvailable):
|
<div class="header-item">
|
||||||
$pinHtml = \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::renderBadge(
|
<a href="#" class="header-item-content" id="mokosuiteclient-clear-temp" title="Clear temp directory">
|
||||||
['available' => true, 'pin' => $pin],
|
<div class="header-item-icon"><span class="icon-trash" aria-hidden="true" id="mokosuiteclient-temp-icon"></span></div>
|
||||||
$token, 'cache'
|
<div class="header-item-text">Temp</div>
|
||||||
);
|
|
||||||
if (!$frontendUrl) {
|
|
||||||
$pinHtml = str_replace('rounded-0 border-end-0', 'rounded-0 rounded-start border-end-0', $pinHtml);
|
|
||||||
}
|
|
||||||
echo $pinHtml;
|
|
||||||
endif; ?>
|
|
||||||
<a href="#" class="btn btn-sm btn-outline-primary <?php echo ($pinAvailable || $frontendUrl) ? 'rounded-0 border-end-0' : 'rounded-0 rounded-start border-end-0'; ?> d-flex align-items-center gap-1 px-3 py-2" id="mokosuiteclient-clear-cache" title="Clear all Joomla cache" style="font-size:0.8rem;">
|
|
||||||
<span class="icon-bolt" aria-hidden="true" id="mokosuiteclient-cache-icon"></span> Cache
|
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="btn btn-sm btn-outline-danger rounded-0 rounded-end d-flex align-items-center gap-1 px-3 py-2" id="mokosuiteclient-clear-temp" title="Clear temp directory" style="font-size:0.8rem;">
|
|
||||||
<span class="icon-trash" aria-hidden="true" id="mokosuiteclient-temp-icon"></span> Temp
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -95,6 +89,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
setupCleaner('mokosuiteclient-clear-cache', 'mokosuiteclient-cache-icon', '<?php echo $cacheUrl; ?>', '<?php echo $token; ?>');
|
setupCleaner('mokosuiteclient-clear-cache', 'mokosuiteclient-cache-icon', '<?php echo $cacheUrl; ?>', '<?php echo $token; ?>');
|
||||||
setupCleaner('mokosuiteclient-clear-temp', 'mokosuiteclient-temp-icon', '<?php echo $tempUrl; ?>', '<?php echo $token; ?>');
|
setupCleaner('mokosuiteclient-clear-temp', 'mokosuiteclient-temp-icon', '<?php echo $tempUrl; ?>', '<?php echo $token; ?>');
|
||||||
|
|
||||||
|
// Click-to-copy domain
|
||||||
|
var domainEl = document.getElementById('mokosuiteclient-domain');
|
||||||
|
if (domainEl) {
|
||||||
|
domainEl.addEventListener('click', function() {
|
||||||
|
navigator.clipboard.writeText(domainEl.textContent.trim()).then(function() {
|
||||||
|
var orig = domainEl.textContent;
|
||||||
|
domainEl.textContent = 'Copied!';
|
||||||
|
setTimeout(function() { domainEl.textContent = orig; }, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<?php echo \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::renderScript(); ?>
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.48.24</version>
|
<version>02.47.48</version>
|
||||||
<description>MOD_MOKOSUITECLIENT_CATEGORIES_DESC</description>
|
<description>MOD_MOKOSUITECLIENT_CATEGORIES_DESC</description>
|
||||||
<namespace path="src">Moko\Module\MokoSuiteClientCategories</namespace>
|
<namespace path="src">Moko\Module\MokoSuiteClientCategories</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.48.24</version>
|
<version>02.47.48</version>
|
||||||
<description>MOD_MOKOSUITECLIENT_CPANEL_DESC</description>
|
<description>MOD_MOKOSUITECLIENT_CPANEL_DESC</description>
|
||||||
<namespace path="src">Moko\Module\MokoSuiteClientCpanel</namespace>
|
<namespace path="src">Moko\Module\MokoSuiteClientCpanel</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -47,10 +47,30 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
|
|||||||
$data['currentIp'] = $helper->getCurrentIp();
|
$data['currentIp'] = $helper->getCurrentIp();
|
||||||
$data['ssl'] = $helper->getSslStatus();
|
$data['ssl'] = $helper->getSslStatus();
|
||||||
|
|
||||||
// Support PIN via shared helper
|
// Daily support PIN derived from health token + today's date (UTC)
|
||||||
$pinState = \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::getState($db);
|
$data['supportPin'] = '';
|
||||||
$data['supportPinAvailable'] = $pinState['available'];
|
|
||||||
$data['supportPin'] = $pinState['pin'];
|
try
|
||||||
|
{
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select($db->quoteName('params'))
|
||||||
|
->from($db->quoteName('#__extensions'))
|
||||||
|
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
|
||||||
|
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||||
|
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||||
|
);
|
||||||
|
$coreParams = json_decode((string) $db->loadResult());
|
||||||
|
$token = $coreParams->health_api_token ?? '';
|
||||||
|
|
||||||
|
if (!empty($token))
|
||||||
|
{
|
||||||
|
$date = gmdate('Y-m-d');
|
||||||
|
$hash = hash_hmac('sha256', $date, $token);
|
||||||
|
$data['supportPin'] = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (\Throwable $e) {}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,11 +32,9 @@ class CpanelHelper
|
|||||||
$pkgCache = json_decode($db->loadResult() ?? '{}');
|
$pkgCache = json_decode($db->loadResult() ?? '{}');
|
||||||
|
|
||||||
return (object) [
|
return (object) [
|
||||||
'sitename' => $config->get('sitename', ''),
|
|
||||||
'mokosuiteclient_version' => $pkgCache->version ?? '',
|
'mokosuiteclient_version' => $pkgCache->version ?? '',
|
||||||
'joomla_version' => (new Version())->getShortVersion(),
|
'joomla_version' => (new Version())->getShortVersion(),
|
||||||
'php_version' => PHP_VERSION,
|
'php_version' => PHP_VERSION,
|
||||||
'db_type' => $config->get('dbtype', 'mysql'),
|
|
||||||
'debug' => (bool) $config->get('debug'),
|
'debug' => (bool) $config->get('debug'),
|
||||||
'offline' => (bool) $config->get('offline'),
|
'offline' => (bool) $config->get('offline'),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -70,10 +70,9 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<span class="fw-bold"><?php echo htmlspecialchars($siteInfo->sitename ?? ''); ?></span>
|
<span class="fw-bold"><?php echo htmlspecialchars($siteInfo->sitename ?? ''); ?></span>
|
||||||
<span class="badge bg-primary">MokoSuite <?php echo htmlspecialchars($siteInfo->mokosuiteclient_version ?? ''); ?></span>
|
<span class="badge bg-primary">MokoSuite <?php echo htmlspecialchars($siteInfo->mokosuiteclient_version ?? ''); ?></span>
|
||||||
<?php echo \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::renderBadge(
|
<?php if (!empty($supportPin)): ?>
|
||||||
['available' => !empty($supportPinAvailable), 'pin' => $supportPin ?? ''],
|
<span class="badge bg-dark" style="font-family:monospace;letter-spacing:0.08em;cursor:help;" title="Daily verification PIN — rotates at midnight UTC."><span class="icon-key small me-1" aria-hidden="true"></span><?php echo htmlspecialchars($supportPin); ?></span>
|
||||||
$token, 'cpanel'
|
<?php endif; ?>
|
||||||
); ?>
|
|
||||||
<span class="badge bg-secondary">Joomla <?php echo htmlspecialchars($siteInfo->joomla_version ?? ''); ?></span>
|
<span class="badge bg-secondary">Joomla <?php echo htmlspecialchars($siteInfo->joomla_version ?? ''); ?></span>
|
||||||
<span class="badge bg-secondary">PHP <?php echo htmlspecialchars($siteInfo->php_version ?? ''); ?></span>
|
<span class="badge bg-secondary">PHP <?php echo htmlspecialchars($siteInfo->php_version ?? ''); ?></span>
|
||||||
<span class="badge bg-secondary"><?php echo htmlspecialchars($siteInfo->db_type ?? ''); ?></span>
|
<span class="badge bg-secondary"><?php echo htmlspecialchars($siteInfo->db_type ?? ''); ?></span>
|
||||||
@@ -89,4 +88,3 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php echo \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::renderScript(); ?>
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.48.24</version>
|
<version>02.47.48</version>
|
||||||
<description>MokoSuiteClient admin sidebar menu — renders a dedicated MokoSuiteClient section in the admin menu before Joomla's default menu.</description>
|
<description>MokoSuiteClient admin sidebar menu — renders a dedicated MokoSuiteClient section in the admin menu before Joomla's default menu.</description>
|
||||||
<namespace path="src">Moko\Module\MokoSuiteClientMenu</namespace>
|
<namespace path="src">Moko\Module\MokoSuiteClientMenu</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -17,26 +17,17 @@ $app = Factory::getApplication();
|
|||||||
$currentOption = $app->getInput()->get('option', '');
|
$currentOption = $app->getInput()->get('option', '');
|
||||||
$currentView = $app->getInput()->get('view', '');
|
$currentView = $app->getInput()->get('view', '');
|
||||||
|
|
||||||
// ── Static views for com_mokosuiteclient (ACL-gated) ──────────────────────
|
// ── Static views for com_mokosuiteclient ──────────────────────────────────
|
||||||
$user = $app->getIdentity();
|
$mokosuiteclientStaticViews = [
|
||||||
$allViews = [
|
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuiteclient'],
|
||||||
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuiteclient', 'acl' => 'mokosuiteclient.dashboard'],
|
['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuiteclient&view=extensions'],
|
||||||
['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuiteclient&view=extensions', 'acl' => 'mokosuiteclient.extensions'],
|
['icon' => 'fa-solid fa-file-code', 'title' => '.htaccess Maker', 'link' => 'index.php?option=com_mokosuiteclient&view=htaccess'],
|
||||||
['icon' => 'fa-solid fa-file-code', 'title' => '.htaccess Maker', 'link' => 'index.php?option=com_mokosuiteclient&view=htaccess', 'acl' => 'mokosuiteclient.htaccess'],
|
['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokosuiteclient&view=privacy'],
|
||||||
['icon' => 'icon-shield-alt', 'title' => 'WAF Log', 'link' => 'index.php?option=com_mokosuiteclient&view=waflog', 'acl' => 'mokosuiteclient.security.waflog'],
|
['icon' => 'icon-shield-alt', 'title' => 'WAF Log', 'link' => 'index.php?option=com_mokosuiteclient&view=waflog'],
|
||||||
['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokosuiteclient&view=privacy', 'acl' => 'core.admin'],
|
['icon' => 'icon-database', 'title' => 'Database Tools', 'link' => 'index.php?option=com_mokosuiteclient&view=database'],
|
||||||
['icon' => 'fa-solid fa-code', 'title' => 'Snippets', 'link' => 'index.php?option=com_mokosuiteclient&view=snippets', 'acl' => 'mokosuiteclient.snippets.manage'],
|
['icon' => 'icon-trash', 'title' => 'Cache Cleanup', 'link' => 'index.php?option=com_mokosuiteclient&view=cleanup'],
|
||||||
['icon' => 'fa-solid fa-file-lines', 'title' => 'Templates', 'link' => 'index.php?option=com_mokosuiteclient&view=templates', 'acl' => 'mokosuiteclient.templates.manage'],
|
['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuiteclient'],
|
||||||
['icon' => 'fa-solid fa-right-left', 'title' => 'Replacements', 'link' => 'index.php?option=com_mokosuiteclient&view=replacements','acl' => 'mokosuiteclient.replacements.manage'],
|
|
||||||
['icon' => 'fa-solid fa-shuffle', 'title' => 'Conditions', 'link' => 'index.php?option=com_mokosuiteclient&view=conditions', 'acl' => 'mokosuiteclient.conditions.manage'],
|
|
||||||
['icon' => 'icon-database', 'title' => 'Database Tools', 'link' => 'index.php?option=com_mokosuiteclient&view=database', 'acl' => 'core.admin'],
|
|
||||||
['icon' => 'icon-trash', 'title' => 'Cache Cleanup', 'link' => 'index.php?option=com_mokosuiteclient&view=cleanup', 'acl' => 'mokosuiteclient.cache'],
|
|
||||||
['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuiteclient', 'acl' => 'core.admin'],
|
|
||||||
];
|
];
|
||||||
$isSuper = $user->authorise('core.admin', 'com_mokosuiteclient');
|
|
||||||
$mokosuiteclientStaticViews = array_filter($allViews, function ($v) use ($user, $isSuper) {
|
|
||||||
return $isSuper || $user->authorise($v['acl'], 'com_mokosuiteclient');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Auto-discover all Moko components from #__menu ──────────────────
|
// ── Auto-discover all Moko components from #__menu ──────────────────
|
||||||
$mokoComponents = [];
|
$mokoComponents = [];
|
||||||
@@ -65,15 +56,6 @@ try
|
|||||||
{
|
{
|
||||||
$lang->load($m->element . '.sys', JPATH_ADMINISTRATOR);
|
$lang->load($m->element . '.sys', JPATH_ADMINISTRATOR);
|
||||||
$lang->load($m->element, JPATH_ADMINISTRATOR);
|
$lang->load($m->element, JPATH_ADMINISTRATOR);
|
||||||
|
|
||||||
// Also try component-local language path (Joomla 5/6 pattern)
|
|
||||||
$compLangPath = JPATH_ADMINISTRATOR . '/components/' . $m->element;
|
|
||||||
if (is_dir($compLangPath . '/language'))
|
|
||||||
{
|
|
||||||
$lang->load($m->element . '.sys', $compLangPath);
|
|
||||||
$lang->load($m->element, $compLangPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
$loadedLangs[$m->element] = true;
|
$loadedLangs[$m->element] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,67 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoSuiteClient
|
|
||||||
* @subpackage plg_system_mokosuiteclient
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*
|
|
||||||
* FILE INFORMATION
|
|
||||||
* DEFGROUP: Joomla.Plugin
|
|
||||||
* INGROUP: MokoSuiteClient
|
|
||||||
* VERSION: 02.48.24
|
|
||||||
* PATH: /src/Field/ArticlesField.php
|
|
||||||
* BRIEF: List field that populates with published Joomla articles
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Plugin\System\MokoSuiteClient\Field;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Form\Field\ListField;
|
|
||||||
use Joomla\CMS\HTML\HTMLHelper;
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Form field that renders a dropdown (or multi-select) of all published
|
|
||||||
* articles, grouped by category name.
|
|
||||||
*
|
|
||||||
* Usage in XML:
|
|
||||||
* <field name="related_article" type="Articles" label="Related Article" multiple="true" />
|
|
||||||
*
|
|
||||||
* @since 02.47.62
|
|
||||||
*/
|
|
||||||
class ArticlesField extends ListField
|
|
||||||
{
|
|
||||||
protected $type = 'Articles';
|
|
||||||
|
|
||||||
protected function getOptions(): array
|
|
||||||
{
|
|
||||||
$options = parent::getOptions();
|
|
||||||
|
|
||||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select([
|
|
||||||
$db->quoteName('a.id', 'value'),
|
|
||||||
$db->quoteName('a.title', 'text'),
|
|
||||||
$db->quoteName('c.title', 'category'),
|
|
||||||
])
|
|
||||||
->from($db->quoteName('#__content', 'a'))
|
|
||||||
->leftJoin($db->quoteName('#__categories', 'c') . ' ON c.id = a.catid')
|
|
||||||
->where($db->quoteName('a.state') . ' = 1')
|
|
||||||
->order($db->quoteName('a.title') . ' ASC');
|
|
||||||
|
|
||||||
$db->setQuery($query);
|
|
||||||
$articles = $db->loadObjectList() ?: [];
|
|
||||||
|
|
||||||
foreach ($articles as $article) {
|
|
||||||
$label = $article->text;
|
|
||||||
if (!empty($article->category)) {
|
|
||||||
$label .= ' [' . $article->category . ']';
|
|
||||||
}
|
|
||||||
$options[] = HTMLHelper::_('select.option', $article->value, $label);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $options;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: Joomla.Plugin
|
* DEFGROUP: Joomla.Plugin
|
||||||
* INGROUP: MokoSuiteClient
|
* INGROUP: MokoSuiteClient
|
||||||
* VERSION: 02.48.24
|
* VERSION: 02.47.48
|
||||||
* PATH: /src/Field/CopyableTokenField.php
|
* PATH: /src/Field/CopyableTokenField.php
|
||||||
* BRIEF: Read-only token field with a copy-to-clipboard button
|
* BRIEF: Read-only token field with a copy-to-clipboard button
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
|
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.48.24</version>
|
<version>02.47.48</version>
|
||||||
<description>MokoSuiteClient core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.</description>
|
<description>MokoSuiteClient core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.</description>
|
||||||
<namespace path=".">Moko\Plugin\System\MokoSuiteClient</namespace>
|
<namespace path=".">Moko\Plugin\System\MokoSuiteClient</namespace>
|
||||||
<scriptfile>script.php</scriptfile>
|
<scriptfile>script.php</scriptfile>
|
||||||
@@ -99,93 +99,6 @@
|
|||||||
description="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_DESC"
|
description="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_DESC"
|
||||||
filter="url" />
|
filter="url" />
|
||||||
|
|
||||||
<field name="auto_clear_cache" type="radio" default="0"
|
|
||||||
label="Auto-Clear Cache on Save"
|
|
||||||
description="Automatically clear Joomla cache when articles, modules, or extensions are saved."
|
|
||||||
class="btn-group btn-group-yesno">
|
|
||||||
<option value="1">JYES</option>
|
|
||||||
<option value="0">JNO</option>
|
|
||||||
</field>
|
|
||||||
|
|
||||||
<field name="protect_emails" type="radio" default="0"
|
|
||||||
label="Email Protection"
|
|
||||||
description="Obfuscate email addresses in HTML output to prevent spam bot harvesting. Uses JavaScript decloaking."
|
|
||||||
class="btn-group btn-group-yesno">
|
|
||||||
<option value="1">JYES</option>
|
|
||||||
<option value="0">JNO</option>
|
|
||||||
</field>
|
|
||||||
|
|
||||||
<field name="snippets_enabled" type="radio" default="0"
|
|
||||||
label="Snippets"
|
|
||||||
description="Enable {snippet alias="name"} content tags. Reusable text/HTML blocks stored in the database with variable substitution support."
|
|
||||||
class="btn-group btn-group-yesno">
|
|
||||||
<option value="1">JYES</option>
|
|
||||||
<option value="0">JNO</option>
|
|
||||||
</field>
|
|
||||||
|
|
||||||
<field name="content_templates_enabled" type="radio" default="0"
|
|
||||||
label="Content Templates"
|
|
||||||
description="Enable {template alias="name"} content tags. Loads structured template data from the content_templates table and renders introtext + fulltext."
|
|
||||||
class="btn-group btn-group-yesno">
|
|
||||||
<option value="1">JYES</option>
|
|
||||||
<option value="0">JNO</option>
|
|
||||||
</field>
|
|
||||||
|
|
||||||
<field name="articles_anywhere_enabled" type="radio" default="0"
|
|
||||||
label="Articles Anywhere"
|
|
||||||
description="Enable {article id="42"}[title]{/article} content tags. Insert article data anywhere using template placeholders for title, introtext, author, category, dates, images, and more."
|
|
||||||
class="btn-group btn-group-yesno">
|
|
||||||
<option value="1">JYES</option>
|
|
||||||
<option value="0">JNO</option>
|
|
||||||
</field>
|
|
||||||
|
|
||||||
<field name="users_anywhere_enabled" type="radio" default="0"
|
|
||||||
label="Users Anywhere"
|
|
||||||
description="Allow {user} tags to display user information in content. Use {user id="42"}[name]{/user} for specific users or {user name} for the current logged-in user."
|
|
||||||
class="btn-group btn-group-yesno">
|
|
||||||
<option value="1">JYES</option>
|
|
||||||
<option value="0">JNO</option>
|
|
||||||
</field>
|
|
||||||
|
|
||||||
<field name="users_allow_email" type="radio" default="0"
|
|
||||||
label="Users: Show Email"
|
|
||||||
description="Allow the [email] placeholder in {user} tags to display the real email address. When disabled, emails are masked."
|
|
||||||
class="btn-group btn-group-yesno"
|
|
||||||
showon="users_anywhere_enabled:1">
|
|
||||||
<option value="1">JYES</option>
|
|
||||||
<option value="0">JNO</option>
|
|
||||||
</field>
|
|
||||||
|
|
||||||
<field name="users_allow_username" type="radio" default="1"
|
|
||||||
label="Users: Show Username"
|
|
||||||
description="Allow the [username] placeholder in {user} tags to display the real username. When disabled, usernames are masked."
|
|
||||||
class="btn-group btn-group-yesno"
|
|
||||||
showon="users_anywhere_enabled:1">
|
|
||||||
<option value="1">JYES</option>
|
|
||||||
<option value="0">JNO</option>
|
|
||||||
</field>
|
|
||||||
|
|
||||||
<field name="replacements_enabled" type="radio" default="0"
|
|
||||||
label="ReReplacer"
|
|
||||||
description="Enable backend-managed string and regex replacement rules. Published rules from the replacements table are applied to site and/or admin content."
|
|
||||||
class="btn-group btn-group-yesno">
|
|
||||||
<option value="1">JYES</option>
|
|
||||||
<option value="0">JNO</option>
|
|
||||||
</field>
|
|
||||||
|
|
||||||
<field name="sourcerer_enabled" type="radio" default="0"
|
|
||||||
label="Code Embedding (Sourcerer)"
|
|
||||||
description="Allow embedding PHP, JavaScript, and CSS code in content via {source} tags. Security restricted."
|
|
||||||
class="btn-group btn-group-yesno">
|
|
||||||
<option value="1">JYES</option>
|
|
||||||
<option value="0">JNO</option>
|
|
||||||
</field>
|
|
||||||
|
|
||||||
<field name="sourcerer_forbidden_functions" type="text" default="exec,system,passthru,shell_exec,popen,proc_open,dl,eval,call_user_func,call_user_func_array,assert,array_map,array_filter,array_walk,usort,uasort,uksort,create_function,preg_replace_callback,ob_start"
|
|
||||||
label="Forbidden PHP Functions"
|
|
||||||
description="Comma-separated list of PHP functions blocked in {source} tags. Backtick operator is always blocked."
|
|
||||||
showon="sourcerer_enabled:1" />
|
|
||||||
|
|
||||||
<field name="monitor_signing_key" type="hidden"
|
<field name="monitor_signing_key" type="hidden"
|
||||||
default="LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktZd2dnU2lBZ0VBQW9JQkFRQ2xZNnNzOTZpeTZOOGMKTHRxbndhbnU4eEozdDcrdDhXT3hoY0Yyclc2QmlmOVhNaEpnYkw0c055N0wwV1dTT2tkMmZxalBNcDFtOFNyNAo1VnNycjE3cFc5b0FNMmtmdFdsaTZ1NkhTVEYyN2pVVUJrT3o4MHZMRklMMGNGNkJCUkpYN2JVWkRpamdUMjc1ClREb3dXZy82Zk9GeWFEelBHUkJuYXFacTljU2lEYWoyNlpSTVZIbktQUERWTG92VzRPTDQzL2gwZ3BtN25nUGIKdWJlLzFFTDRUMHFRbm1Xc2FEOFZ6VStoRXFGSDRTVUtMaDVNeklGbUxFZzRlZ0xCbTBXcWdxbzZRQVBnZDVPYgoybXhmQndta3RLVm5hcWR6eG9KSytzaTVuZkYreGpxbWRMZThUdmEyTHNuTUxlZmsrODVoQ3hxS2x1eWRta1lXCjlvUk5qcDhiQWdNQkFBRUNnZ0VBQkZOUS9NSVZaV2gxdlZUMFh3TFBvUEkyZjI4TTBrM0gzN0t4MXBxK2t5QzYKenRyK1pBczBCaEFEWjAwNHJOUmRYaG45N0QxVXBJYVdLeUJFZkNZQUEzWmxneS9WQmdGR21sR3VuMWNvdGdXUQoyYzg0SWhLdzNzVFFqL2dJWUxOelFWMTBLUTJYd0JZVHZ1MWhjRFpLeUxCUGJTQ1F4cEhQUGdVcUNRNFljR3lFClErVmc1dHJUYk8wQ2xCZ1U5bkVnYU1RakRJZ0F3WVZPV203dUxJTW84UC9nT3FuT2tmaFhzdzl3VTJVYWxFeTEKRmRZbGhMbGJ0ZS9MZ3lkYlJ2RStjNEtqZVp0Z3ptc1RneEh2dzM5YVVmZUZTclFRT0FjcXc0alNzUjdMck9UZAp5bDhpelRrZVBrTVFMamFqR0pabWdPbitkRzhtUlpMa3FKcWdGaVpqRVFLQmdRRFV0L0xlU0h5SmhvY3VFL240CkZreEpaclJoWUVsWnc2WlZJUnQzWDlPQ1Nmaklab3I1ZkZlczhvUzZySFhKdGZYeWx4QUxOSjJjTUhKTTViVnUKbUFSUFU4cThBeVc0OE03cHAyNmtVVTMxNXc2OU1SUkhzbWgyekRabEtDeG5GM1NSQ3U4YW95d3hZc3RUZ3hkTgo2bDhLNHZsS1dsN3FYblBhWjZjb3lQSU9od0tCZ1FESENuRmRRdW5SMVI2dkxGaVFZMTRiT3QwT0tzVGJYMUJyCmpvUGZySkxvRm5mSCs4VDVnNUdxYkV5T2p0WG1tRXhmTFFpcDBQVXRtc1E0YXlJRFBZYWZtU3RpK2dtQXZFd1MKZTlKcVYxYlRuazUrYnVRZ2FlOW16REpJWkxaczRJUlhrd1Q5aDZ4Q2xKeS80TGJSRHdBU3dUVGJlY01hN3A4UgpQN0p0bjdsYnpRS0JnQzNOR2FjUTFuZktGb3N1VS9FOTQ5a2VHeEtvWjhMREpLcEp3WjgzYTlRdTF6bFhFdTlhCi9ZbklnaG1yam9VSy85VG0vOVpaMHVIUmNKcnNEdCtzTGFsaThsRC9JSDBzcEhDYzAyN2Y3cmhXc3M2N3BaRTIKY2RXNmJLL2xNWUpWQTQxRFhHNVEyZkFjUklsTHZaWFNNL3FsR21ZUEJVYlRaWUNPTnVqS000dzdBb0dBU1dBdwpLcEZnWVZxUDFVUWo0aGEvdW9vWXRBQlFVZzd4TnJWektDSVdoampDTDVkQkpqcTZtSGtVUC9tb0lUcEQ3VkpNCnYwMnBGUWJaRDNOdk5vS1gvbjRZNElRTXZNaXR3cUtqRDFEalVXQXF6N0ZScUNGbGdDQUc2V2szVnl2dG5kczEKRzhISVgwTXFCaEp4VXVDVXhsVXpoelY4RjVHZ1VsdUpDNkMyVklFQ2dZQkJWSkxpZlNVOTlHWGZtK3dPd0RWcgo2bHZoUFgxOTBGVktWQXY3aVVWTXBwWXg4Y0QxYkcyUjRLT29JbnkxYTlxdjA2ZGFzeGVQOStkVjJVMWU3MWl5CkFXWDRBVHIrYitvSGk2eUk1MXRHRk54RUxiNXZYMVpYM3VNaDlWM29iYUpuSFNjYllpKzBBNjlyRmNuNEZuLzUKWXJybWxLTzRlRHFVZkswbVFJVCtwUT09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K"
|
default="LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktZd2dnU2lBZ0VBQW9JQkFRQ2xZNnNzOTZpeTZOOGMKTHRxbndhbnU4eEozdDcrdDhXT3hoY0Yyclc2QmlmOVhNaEpnYkw0c055N0wwV1dTT2tkMmZxalBNcDFtOFNyNAo1VnNycjE3cFc5b0FNMmtmdFdsaTZ1NkhTVEYyN2pVVUJrT3o4MHZMRklMMGNGNkJCUkpYN2JVWkRpamdUMjc1ClREb3dXZy82Zk9GeWFEelBHUkJuYXFacTljU2lEYWoyNlpSTVZIbktQUERWTG92VzRPTDQzL2gwZ3BtN25nUGIKdWJlLzFFTDRUMHFRbm1Xc2FEOFZ6VStoRXFGSDRTVUtMaDVNeklGbUxFZzRlZ0xCbTBXcWdxbzZRQVBnZDVPYgoybXhmQndta3RLVm5hcWR6eG9KSytzaTVuZkYreGpxbWRMZThUdmEyTHNuTUxlZmsrODVoQ3hxS2x1eWRta1lXCjlvUk5qcDhiQWdNQkFBRUNnZ0VBQkZOUS9NSVZaV2gxdlZUMFh3TFBvUEkyZjI4TTBrM0gzN0t4MXBxK2t5QzYKenRyK1pBczBCaEFEWjAwNHJOUmRYaG45N0QxVXBJYVdLeUJFZkNZQUEzWmxneS9WQmdGR21sR3VuMWNvdGdXUQoyYzg0SWhLdzNzVFFqL2dJWUxOelFWMTBLUTJYd0JZVHZ1MWhjRFpLeUxCUGJTQ1F4cEhQUGdVcUNRNFljR3lFClErVmc1dHJUYk8wQ2xCZ1U5bkVnYU1RakRJZ0F3WVZPV203dUxJTW84UC9nT3FuT2tmaFhzdzl3VTJVYWxFeTEKRmRZbGhMbGJ0ZS9MZ3lkYlJ2RStjNEtqZVp0Z3ptc1RneEh2dzM5YVVmZUZTclFRT0FjcXc0alNzUjdMck9UZAp5bDhpelRrZVBrTVFMamFqR0pabWdPbitkRzhtUlpMa3FKcWdGaVpqRVFLQmdRRFV0L0xlU0h5SmhvY3VFL240CkZreEpaclJoWUVsWnc2WlZJUnQzWDlPQ1Nmaklab3I1ZkZlczhvUzZySFhKdGZYeWx4QUxOSjJjTUhKTTViVnUKbUFSUFU4cThBeVc0OE03cHAyNmtVVTMxNXc2OU1SUkhzbWgyekRabEtDeG5GM1NSQ3U4YW95d3hZc3RUZ3hkTgo2bDhLNHZsS1dsN3FYblBhWjZjb3lQSU9od0tCZ1FESENuRmRRdW5SMVI2dkxGaVFZMTRiT3QwT0tzVGJYMUJyCmpvUGZySkxvRm5mSCs4VDVnNUdxYkV5T2p0WG1tRXhmTFFpcDBQVXRtc1E0YXlJRFBZYWZtU3RpK2dtQXZFd1MKZTlKcVYxYlRuazUrYnVRZ2FlOW16REpJWkxaczRJUlhrd1Q5aDZ4Q2xKeS80TGJSRHdBU3dUVGJlY01hN3A4UgpQN0p0bjdsYnpRS0JnQzNOR2FjUTFuZktGb3N1VS9FOTQ5a2VHeEtvWjhMREpLcEp3WjgzYTlRdTF6bFhFdTlhCi9ZbklnaG1yam9VSy85VG0vOVpaMHVIUmNKcnNEdCtzTGFsaThsRC9JSDBzcEhDYzAyN2Y3cmhXc3M2N3BaRTIKY2RXNmJLL2xNWUpWQTQxRFhHNVEyZkFjUklsTHZaWFNNL3FsR21ZUEJVYlRaWUNPTnVqS000dzdBb0dBU1dBdwpLcEZnWVZxUDFVUWo0aGEvdW9vWXRBQlFVZzd4TnJWektDSVdoampDTDVkQkpqcTZtSGtVUC9tb0lUcEQ3VkpNCnYwMnBGUWJaRDNOdk5vS1gvbjRZNElRTXZNaXR3cUtqRDFEalVXQXF6N0ZScUNGbGdDQUc2V2szVnl2dG5kczEKRzhISVgwTXFCaEp4VXVDVXhsVXpoelY4RjVHZ1VsdUpDNkMyVklFQ2dZQkJWSkxpZlNVOTlHWGZtK3dPd0RWcgo2bHZoUFgxOTBGVktWQXY3aVVWTXBwWXg4Y0QxYkcyUjRLT29JbnkxYTlxdjA2ZGFzeGVQOStkVjJVMWU3MWl5CkFXWDRBVHIrYitvSGk2eUk1MXRHRk54RUxiNXZYMVpYM3VNaDlWM29iYUpuSFNjYllpKzBBNjlyRmNuNEZuLzUKWXJybWxLTzRlRHFVZkswbVFJVCtwUT09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K"
|
||||||
filter="raw" />
|
filter="raw" />
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
* DEFGROUP: Joomla.Plugin
|
* DEFGROUP: Joomla.Plugin
|
||||||
* INGROUP: MokoSuiteClient
|
* INGROUP: MokoSuiteClient
|
||||||
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
* VERSION: 02.48.24
|
* VERSION: 02.47.48
|
||||||
* PATH: /src/script.php
|
* PATH: /src/script.php
|
||||||
* BRIEF: Installation script for MokoSuiteClient plugin
|
* BRIEF: Installation script for MokoSuiteClient plugin
|
||||||
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
|
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
* DEFGROUP: Joomla.Plugin
|
* DEFGROUP: Joomla.Plugin
|
||||||
* INGROUP: MokoSuiteClient
|
* INGROUP: MokoSuiteClient
|
||||||
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||||
* VERSION: 02.48.24
|
* VERSION: 02.47.48
|
||||||
* PATH: /src/services/provider.php
|
* PATH: /src/services/provider.php
|
||||||
* BRIEF: Service provider for dependency injection in Joomla 5.x
|
* BRIEF: Service provider for dependency injection in Joomla 5.x
|
||||||
* NOTE: Registers the plugin with Joomla's DI container
|
* NOTE: Registers the plugin with Joomla's DI container
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.48.24</version>
|
<version>02.47.48</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientBackup</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteClientBackup</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.48.24</version>
|
<version>02.47.48</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDBIP</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDBIP</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ class DBIP extends CMSPlugin implements SubscriberInterface
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'onAfterInitialise' => 'onAfterInitialise',
|
'onAfterInitialise' => 'onAfterInitialise',
|
||||||
'onAfterRender' => 'onAfterRender',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,92 +80,4 @@ class DBIP extends CMSPlugin implements SubscriberInterface
|
|||||||
|
|
||||||
DBIPHelper::downloadCityDb($url);
|
DBIPHelper::downloadCityDb($url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan rendered admin HTML for IP addresses and enrich with geo flags + tooltips.
|
|
||||||
*/
|
|
||||||
public function onAfterRender(): void
|
|
||||||
{
|
|
||||||
$app = $this->getApplication();
|
|
||||||
|
|
||||||
if (!$app->isClient('administrator'))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($app->getDocument()->getType() !== 'html')
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$body = $app->getBody();
|
|
||||||
|
|
||||||
if (empty($body))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ipv4 = '(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)';
|
|
||||||
$ipv6 = '(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}(?::(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))?';
|
|
||||||
$pattern = '#<code(?:\s[^>]*)?>(' . $ipv4 . '|' . $ipv6 . ')</code>#';
|
|
||||||
|
|
||||||
$cache = [];
|
|
||||||
|
|
||||||
$newBody = preg_replace_callback($pattern, function ($m) use (&$cache) {
|
|
||||||
$fullMatch = $m[0];
|
|
||||||
$ip = $m[1];
|
|
||||||
|
|
||||||
// Skip private/loopback
|
|
||||||
if (filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE) === false)
|
|
||||||
{
|
|
||||||
return $fullMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already enriched (has a title attribute)
|
|
||||||
if (strpos($fullMatch, 'title=') !== false)
|
|
||||||
{
|
|
||||||
return $fullMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($cache[$ip]))
|
|
||||||
{
|
|
||||||
$cache[$ip] = DBIPHelper::lookup($ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
$geo = $cache[$ip];
|
|
||||||
|
|
||||||
if ($geo === null || empty($geo['country_code']))
|
|
||||||
{
|
|
||||||
return $fullMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cc = strtoupper($geo['country_code']);
|
|
||||||
$flag = self::countryFlag($cc);
|
|
||||||
|
|
||||||
$parts = array_filter([$geo['city'], $geo['region'], $geo['country_name']]);
|
|
||||||
$tooltip = htmlspecialchars(implode(', ', $parts), \ENT_QUOTES, 'UTF-8');
|
|
||||||
$escaped = htmlspecialchars($ip, \ENT_QUOTES, 'UTF-8');
|
|
||||||
|
|
||||||
return $flag . ' <code title="' . $tooltip . '" style="cursor:help;">' . $escaped . '</code>';
|
|
||||||
}, $body);
|
|
||||||
|
|
||||||
if ($newBody !== null && $newBody !== $body)
|
|
||||||
{
|
|
||||||
$app->setBody($newBody);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function countryFlag(string $cc): string
|
|
||||||
{
|
|
||||||
if (\strlen($cc) !== 2)
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$cc = strtoupper($cc);
|
|
||||||
$first = mb_chr(0x1F1E6 + \ord($cc[0]) - \ord('A'));
|
|
||||||
$second = mb_chr(0x1F1E6 + \ord($cc[1]) - \ord('A'));
|
|
||||||
|
|
||||||
return $first . $second;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.48.24</version>
|
<version>02.47.48</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDevTools</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDevTools</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.48.24</version>
|
<version>02.47.48</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientFirewall</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteClientFirewall</namespace>
|
||||||
|
|
||||||
|
|||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE="System - MokoSuiteClient License"
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC="Validates download/update keys against the MokoSuite license server."
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_FIELDSET_BASIC="License Settings"
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_FIELDSET_BASIC_DESC="Configure the license server connection and caching behaviour."
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_SERVER_URL_LABEL="License Server URL"
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_SERVER_URL_DESC="Base URL of the MokoSuite license server (Gitea instance)."
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_ORG_KEY_LABEL="Organisation API Key"
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_ORG_KEY_DESC="API token used for licence validation requests."
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_CACHE_TTL_LABEL="Cache TTL (hours)"
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_CACHE_TTL_DESC="How long a successful licence check is cached before re-validation."
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_GRACE_LABEL="Grace Period (hours)"
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_GRACE_DESC="How long the site continues to function after a failed licence check."
|
|
||||||
-2
@@ -1,2 +0,0 @@
|
|||||||
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE="System - MokoSuiteClient License"
|
|
||||||
PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC="Validates download/update keys against the MokoSuite license server."
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.48.24</version>
|
<version>02.47.48</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientLicense</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteClientLicense</namespace>
|
||||||
<files><folder>src</folder><folder>services</folder><folder>language</folder></files>
|
<files><folder>src</folder><folder>services</folder><folder>language</folder></files>
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoSuiteClient
|
|
||||||
* @subpackage plg_system_mokosuiteclient_license
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Plugin\System\MokoSuiteClientLicense\Extension;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Plugin\CMSPlugin;
|
|
||||||
use Joomla\Event\SubscriberInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MokoSuiteClient License Plugin
|
|
||||||
*
|
|
||||||
* Validates download/update keys against the configured license server
|
|
||||||
* and caches the result for the configured TTL.
|
|
||||||
*
|
|
||||||
* @since 02.34.84
|
|
||||||
*/
|
|
||||||
class License extends CMSPlugin implements SubscriberInterface
|
|
||||||
{
|
|
||||||
protected $autoloadLanguage = true;
|
|
||||||
|
|
||||||
public static function getSubscribedEvents(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.48.24</version>
|
<version>02.47.48</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientOffline</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteClientOffline</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.48.24</version>
|
<version>02.47.48</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_TENANT_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITECLIENT_TENANT_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientTenant</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteClientTenant</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.48.24</version>
|
<version>02.47.48</version>
|
||||||
<description>PLG_TASK_MOKOSUITECLIENTDEMO_DESC</description>
|
<description>PLG_TASK_MOKOSUITECLIENTDEMO_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientDemo</namespace>
|
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientDemo</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: MokoSuiteClient
|
* INGROUP: MokoSuiteClient
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||||
* PATH: /src/packages/plg_system_mokosuiteclient/Service/DemoResetService.php
|
* PATH: /src/packages/plg_system_mokosuiteclient/Service/DemoResetService.php
|
||||||
* VERSION: 02.48.24
|
* VERSION: 02.47.48
|
||||||
* BRIEF: Content-only snapshot/restore for demo site reset
|
* BRIEF: Content-only snapshot/restore for demo site reset
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user