Compare commits

..

52 Commits

Author SHA1 Message Date
gitea-actions[bot] 5e889bbcff chore: promote changelog [Unreleased] → [02.46.99] 2026-06-21 23:21:59 +00:00
gitea-actions[bot] c46373265d chore(release): build 02.46.99 [skip ci]
Publish to Composer / Publish Package (release) Failing after 43s
2026-06-21 23:21:54 +00:00
jmiller 8044106f19 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 23:20:45 +00:00
jmiller 291f04eb81 Merge pull request 'chore: remove automation directory' (#234) from fix/remove-automation into main
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 1m0s
2026-06-21 23:09:57 +00:00
gitea-actions[bot] dca4ef89a9 chore(version): pre-release bump to 02.46.99-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 41s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 26s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 14m26s
2026-06-21 23:08:23 +00:00
Jonathan Miller ffa9edd33f chore: remove automation directory
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 8s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 12s
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 12s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 13s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 1m17s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 27s
Universal: Build & Release / Promote to RC (pull_request) Failing after 13s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
2026-06-21 18:02:48 -05:00
jmiller 70fe78e064 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-21 22:02:18 +00:00
jmiller ceb3cfacf7 chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-21 16:05:05 +00:00
jmiller 6ecaf9923d chore: sync composer-publish.yml from Template-Generic [skip ci] 2026-06-21 06:34:27 +00:00
jmiller e8e8c689e8 chore: sync workflow-sync-trigger.yml from Template-Generic [skip ci] 2026-06-21 01:27:18 +00:00
jmiller d26ada7d18 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-21 01:27:14 +00:00
jmiller f6e7082f44 ci: sync rc-revert.yml from Template-Joomla [skip ci] 2026-06-21 00:15:04 +00:00
jmiller 5c048ef5db ci: sync issue-branch.yml from Template-Joomla [skip ci] 2026-06-21 00:14:35 +00:00
jmiller 10b597b248 ci: sync ci-joomla.yml from Template-Joomla [skip ci] 2026-06-21 00:14:11 +00:00
jmiller 7f272aabf9 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-20 23:45:48 +00:00
jmiller 44030cdc9c chore: sync gitleaks.yml from Template-Generic [skip ci] 2026-06-20 23:45:48 +00:00
jmiller e21e345389 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-20 23:45:47 +00:00
jmiller 43646e826d chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-20 23:07:34 +00:00
gitea-actions[bot] 91542cf759 chore: promote changelog [Unreleased] → [02.45.00] 2026-06-20 23:07:33 +00:00
gitea-actions[bot] c4a77e2da7 chore(release): build 02.45.00 [skip ci] 2026-06-20 23:07:26 +00:00
jmiller 156cb1713f Merge pull request 'fix(heartbeat): align signature headers with HQ expectations' (#222) from rc into main
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 28s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
2026-06-20 23:07:02 +00:00
jmiller 59f37f09cf chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-20 22:29:48 +00:00
jmiller 1308497e39 chore: sync rc-revert.yml from Template-Generic [skip ci] 2026-06-20 22:29:48 +00:00
jmiller 481893e182 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-20 22:29:47 +00:00
jmiller 8606acf2fd chore: sync cleanup.yml from Template-Generic [skip ci] 2026-06-20 22:29:46 +00:00
jmiller 71a9da3f72 ci: sync security-audit.yml from Template-Joomla [skip ci] 2026-06-20 22:26:30 +00:00
jmiller c42b65ed38 ci: sync repo-health.yml from Template-Joomla [skip ci] 2026-06-20 22:26:01 +00:00
jmiller 9949bf7fda ci: sync rc-revert.yml from Template-Joomla [skip ci] 2026-06-20 22:25:52 +00:00
jmiller 44ca197c36 ci: sync pr-check.yml from Template-Joomla [skip ci] 2026-06-20 22:24:45 +00:00
jmiller 706c088da1 ci: sync issue-branch.yml from Template-Joomla [skip ci] 2026-06-20 22:22:20 +00:00
jmiller 1ebba18c16 ci: sync cleanup.yml from Template-Joomla [skip ci] 2026-06-20 22:15:33 +00:00
jmiller 70c2aaae05 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-20 21:35:12 +00:00
jmiller 9085ccf474 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-20 21:35:11 +00:00
jmiller 9d22ba0b10 ci: sync ci-generic.yml from Template-Joomla [skip ci] 2026-06-20 21:34:01 +00:00
jmiller f03a522bb9 ci: sync cascade-dev.yml from Template-Joomla [skip ci] 2026-06-20 21:31:32 +00:00
jmiller 344673ab8a ci: sync branch-cleanup.yml from Template-Joomla [skip ci] 2026-06-20 21:28:07 +00:00
jmiller 43b8549402 ci: sync auto-release.yml from Template-Joomla [skip ci] 2026-06-20 21:26:56 +00:00
gitea-actions[bot] a4f55f6ba7 chore: promote changelog [Unreleased] → [02.44.00] 2026-06-20 20:56:18 +00:00
gitea-actions[bot] 222a52580c chore(release): build 02.44.00 [skip ci] 2026-06-20 20:56:10 +00:00
jmiller 1c6c8a8473 Merge pull request 'fix(heartbeat): correct API route from mokosuiteclienthq to mokosuitehq' (#221) from rc into main
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 48s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
2026-06-20 20:52:36 +00:00
jmiller 1964c86ee0 chore: sync workflow-sync-trigger.yml from Template-Generic [skip ci] 2026-06-20 20:50:43 +00:00
jmiller cd7bdc03c8 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-20 20:50:41 +00:00
gitea-actions[bot] 9d8fd4eed1 chore: promote changelog [Unreleased] → [02.43.00] 2026-06-20 20:41:04 +00:00
gitea-actions[bot] 1404b699ad chore(release): build 02.43.00 [skip ci] 2026-06-20 20:40:58 +00:00
jmiller b1d72bc23e ci: sync ci-generic.yml from Template-Joomla [skip ci] 2026-06-20 20:35:04 +00:00
gitea-actions[bot] 67721d0247 chore: promote changelog [Unreleased] → [02.42.00] 2026-06-20 20:33:33 +00:00
gitea-actions[bot] 7687da58c3 chore(release): build 02.42.00 [skip ci] 2026-06-20 20:33:23 +00:00
jmiller a4d9d6d129 Merge pull request 'fix: update heartbeat URL from waas.dev to suite.dev.mokoconsulting.tech' (#220) from rc into main
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 46s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
2026-06-20 20:33:01 +00:00
jmiller 495083f89f ci: sync cascade-dev.yml from Template-Joomla [skip ci] 2026-06-20 20:32:50 +00:00
gitea-actions[bot] f47554e46c chore(version): auto-bump patch 02.41.02-rc [skip ci]
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 28s
2026-06-20 20:32:26 +00:00
jmiller eddc9c2fd4 ci: sync branch-cleanup.yml from Template-Joomla [skip ci] 2026-06-20 20:31:52 +00:00
jmiller b88e68ee10 ci: sync auto-release.yml from Template-Joomla [skip ci] 2026-06-20 20:30:58 +00:00
88 changed files with 4121 additions and 1532 deletions
+59 -5
View File
@@ -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,7 +21,7 @@
# | 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"
@@ -51,7 +51,7 @@ 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
@@ -149,7 +149,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 +205,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,6 +234,54 @@ 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="${GITEA_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="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
-6
View File
@@ -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:
+410 -7
View File
@@ -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
+76
View File
@@ -0,0 +1,76 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
name: "Publish to Composer"
on:
push:
tags:
- 'v*'
- '[0-9]*.[0-9]*.[0-9]*'
release:
types: [published]
workflow_dispatch:
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
publish:
name: Publish Package
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip publish]')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
- name: Install dependencies
run: composer install --no-dev --no-interaction --prefer-dist --quiet
- name: Determine version
id: version
run: |
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Package version: ${VERSION}"
# Gitea Composer Registry — auto-publishes from tags
# The tag push itself registers the package at:
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
- name: Verify Gitea registry
run: |
echo "Gitea Composer registry auto-publishes from tags."
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
echo "Install: composer require mokoconsulting/mokocli"
# Packagist — notify of new version
- name: Notify Packagist
if: secrets.PACKAGIST_TOKEN != ''
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Notifying Packagist of version ${VERSION}..."
curl -sf -X POST \
-H "Content-Type: application/json" \
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
&& echo "Packagist notified" \
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
- name: Summary
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
-4
View File
@@ -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:
+2 -2
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation # INGROUP: mokocli.Automation
# VERSION: 02.47.08 # 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"
+28 -2
View File
@@ -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
+16
View File
@@ -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,6 +178,7 @@ 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 }}"
@@ -176,6 +189,7 @@ jobs:
--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 }}"
@@ -212,6 +226,7 @@ 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 }}"
@@ -225,6 +240,7 @@ 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="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+2 -2
View File
@@ -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
@@ -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
@@ -45,16 +45,16 @@ 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' }}" GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokoplatform.git" /tmp/mokoplatform git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
- 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 +70,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}
+6 -34
View File
@@ -14,49 +14,21 @@
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.47.08 VERSION: 02.46.99
BRIEF: Version history using `Keep a Changelog` BRIEF: Version history using `Keep a Changelog`
--> -->
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
### Added ## [02.46.99] --- 2026-06-21
- **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
- **Domain as support key** — click-to-copy domain in admin status bar
- **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
- **Backup bridge plugin** — discovers MokoSuiteBackup's BackupStatusHelper and sends status in heartbeat payloads
- **Activity log** — blockchain-style hash chain for tamper detection in MokoSuiteHQ
- **Dev domain in heartbeat** — client sends dev alias to HQ for display on dashboard
### Changed ## [02.46.99] --- 2026-06-21
- **Plugin install** — self-healing: extracts plugin zips from package on every update, creates missing extension records with namespace
- **Menu naming** — MokoSuiteClient displays as "MokoSuite", MokoSuiteHQ as "MokoHQ", others stripped of prefix
- **Menu ordering** — HQ first, MokoSuite second, others alphabetical
- **Cpanel module** — always starts collapsed, access level 3 (Special), pretty plugin badge labels
- **Module namespaces** — fixed cpanel (MokoSuiteCpanel → MokoSuiteClientCpanel) and cache (MokoSuiteCache → MokoSuiteClientCache)
- **Health checks** — return status:error on exceptions instead of false status:ok; MokoSuiteBackup detection queries correct table
- **Heartbeat** — correct URL (suite.dev), correct API route (mokosuitehq), correct headers (X-MokoSuite-*), fresh RSA key pair
- **Date formats** — all templates use Joomla locale-aware DATE_FORMAT_LC2/LC4
- **Domains** — updated from waas.dev to suite.dev.mokoconsulting.tech throughout
### Removed ## [02.45.00] --- 2026-06-20
- **Helpdesk/tickets** — migrated to MokoSuiteCRM (issue #67)
- **Monitor plugin** — retired, config consolidated into core plugin
- **Backup bridge** — temporarily removed from package manifest (build pipeline issue)
- **Update server migration** — removed migrateUpdateServerUrls, cleanupStaleUpdateSites, fixUpdateRecords, enableUpdateServer calls
### Fixed ## [02.45.00] --- 2026-06-20
- 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
- Module not publishing (ensureAdminModule direct DB update bypasses checked_out)
- RSA key pair had Windows line endings causing signature verification failure
- Heartbeat connection failing due to wrong domain, route, and header names
## [02.44.00] --- 2026-06-20 ## [02.44.00] --- 2026-06-20
## [02.42.00] --- 2026-06-20 ## [02.44.00] --- 2026-06-20
## [02.42.00] --- 2026-06-20
+1 -1
View File
@@ -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.47.08 VERSION: 02.46.99
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
View File
@@ -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.47.08 VERSION: 02.46.99
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
View File
@@ -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.47.08 VERSION: 02.46.99
BRIEF: Project license (GPL-3.0-or-later) BRIEF: Project license (GPL-3.0-or-later)
--> -->
GNU GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
+9 -32
View File
@@ -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.47.08 VERSION: 02.46.99
PATH: /README.md PATH: /README.md
BRIEF: MokoSuiteClient platform plugin for Joomla BRIEF: MokoSuiteClient platform plugin for Joomla
--> -->
@@ -21,40 +21,17 @@
[![Joomla](https://img.shields.io/badge/Joomla-5.x%20%7C%206.x-red.svg?logo=joomla&logoColor=white)](https://www.joomla.org) [![Joomla](https://img.shields.io/badge/Joomla-5.x%20%7C%206.x-red.svg?logo=joomla&logoColor=white)](https://www.joomla.org)
[![PHP](https://img.shields.io/badge/PHP-8.1%2B-777BB4.svg?logo=php&logoColor=white)](https://www.php.net) [![PHP](https://img.shields.io/badge/PHP-8.1%2B-777BB4.svg?logo=php&logoColor=white)](https://www.php.net)
MokoSuiteClient is the Joomla 5.x / 6.x client-facing tracker and identity layer for the MokoSuite platform. It provides security hardening, health monitoring, privacy compliance, multi-domain management, and integration with MokoSuiteHQ for centralized site management. MokoSuiteClient is a Joomla 5.x / 6.x system plugin package that provides white-label branding, security hardening, tenant restrictions, health monitoring, and multi-domain management for the MokoSuiteClient platform.
## Features ## Features
### Core - **White-Label Branding** — configurable brand name, company, support URL, colors, favicon, custom CSS
- **Admin Dashboard** — site info, plugin status, quick actions, support PIN - **Tenant Restrictions** — master user enforcement, installer/sysinfo/config/template access control
- **Health Monitoring** — 15 diagnostic checks via `/?mokosuiteclient=health` - **Health Monitoring** — 16 diagnostic checks via `/?mokosuiteclient=health` with Grafana auto-provisioning
- **Heartbeat** — RSA-signed registration with MokoSuiteHQ, daily support PIN rotation - **Site Aliases** — per-alias offline mode, robots directives, backend redirect, canonical URLs
- **Extension Catalog** — browse and install Moko Consulting extensions - **Remote API** — 6 endpoints (health, install, update, cache, backup, info)
- **Security Hardening** — HTTPS enforcement, session timeouts, password policy, upload restrictions
### Security (Firewall Plugin) - **Plugin Protection** — protected status, hidden from non-master users, disable/uninstall blocked
- **Web Application Firewall** — SQL injection, XSS, RFI, directory traversal shields
- **Security Headers** — X-Frame-Options, CSP, HSTS, Referrer-Policy, Permissions-Policy
- **IP Management** — trusted IPs, blocklist, auto-ban on WAF threshold
- **Password Policy** — min length, uppercase, number, special character requirements
- **Access Control** — admin secret URL, frontend super user block, upload restrictions
### Privacy Guard
- **GDPR Compliance** — data subject requests, consent logging, retention policies
- **User Data** — export, anonymize, or delete user data on request
### DevTools
- **Development Mode** — debug, cache disable, hit suppression
- **Mirror Domains & Staging** — repeatable table of domain aliases with offline bypass and robots directives
- **Maintenance** — reset hits, delete versions, reset download keys
### Multi-Domain
- **Site Aliases** — per-alias offline mode, robots directives, canonical URLs
- **Offline Bypass** — TOS, privacy policy, and support pages remain accessible when site is offline
### Integration
- **MokoSuiteHQ** — heartbeat, health data, backup status, activity logging
- **MokoSuiteBackup** — bridge plugin discovers BackupStatusHelper for heartbeat payloads
- **Joomla** — guided tours, action logging, custom fields, scheduled tasks
## Requirements ## Requirements
+1 -1
View File
@@ -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.47.08 VERSION: 02.46.99
BRIEF: Security vulnerability reporting and handling policy BRIEF: Security vulnerability reporting and handling policy
--> -->
-237
View File
@@ -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
+2 -2
View File
@@ -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.47.08 VERSION: 02.46.99
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.47.08) # MokoSuiteClient Build Guide (VERSION: 02.46.99)
## 1. Purpose ## 1. Purpose
+2 -2
View File
@@ -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.47.08 VERSION: 02.46.99
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.47.08) # MokoSuiteClient Configuration Guide (VERSION: 02.46.99)
## 1. Objective ## 1. Objective
+2 -2
View File
@@ -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.47.08 VERSION: 02.46.99
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.47.08) # MokoSuiteClient Installation Guide (VERSION: 02.46.99)
## Introduction ## Introduction
+2 -2
View File
@@ -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.47.08 VERSION: 02.46.99
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.47.08) # MokoSuiteClient Operations Guide (VERSION: 02.46.99)
## Introduction ## Introduction
+2 -2
View File
@@ -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.47.08 VERSION: 02.46.99
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.47.08) # MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.46.99)
## Introduction ## Introduction
+2 -2
View File
@@ -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.47.08 VERSION: 02.46.99
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.47.08) # MokoSuiteClient Testing Guide (VERSION: 02.46.99)
## 1. Prerequisites ## 1. Prerequisites
+2 -2
View File
@@ -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.47.08 VERSION: 02.46.99
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.47.08) # MokoSuiteClient Troubleshooting Guide (VERSION: 02.46.99)
## Introduction ## Introduction
+2 -2
View File
@@ -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.47.08 VERSION: 02.46.99
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.47.08) # MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.46.99)
## Introduction ## Introduction
+2 -2
View File
@@ -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.47.08 VERSION: 02.46.99
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.47.08) # MokoSuiteClient Documentation Index (VERSION: 02.46.99)
## Introduction ## Introduction
+2 -2
View File
@@ -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.47.08 VERSION: 02.46.99
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.47.08) # MokoSuiteClient Plugin Overview (VERSION: 02.46.99)
## Introduction ## Introduction
+1 -1
View File
@@ -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.47.08 VERSION: 02.46.99
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
--> -->
@@ -2,9 +2,9 @@
; 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="MokoSuite" COM_MOKOSUITECLIENT="MokoSuiteClient"
COM_MOKOSUITECLIENT_DESCRIPTION="MokoSuite admin dashboard and REST API. Control panel for managing site features, health monitoring, and remote management." COM_MOKOSUITECLIENT_DESCRIPTION="MokoSuiteClient admin dashboard and REST API. Control panel for managing site features, health monitoring, and remote management."
COM_MOKOSUITECLIENT_DASHBOARD_TITLE="MokoSuite Control Panel" COM_MOKOSUITECLIENT_DASHBOARD_TITLE="MokoSuiteClient Control Panel"
COM_MOKOSUITECLIENT_MENU_DASHBOARD="Dashboard" COM_MOKOSUITECLIENT_MENU_DASHBOARD="Dashboard"
COM_MOKOSUITECLIENT_MENU_EXTENSIONS="Moko Extensions" COM_MOKOSUITECLIENT_MENU_EXTENSIONS="Moko Extensions"
COM_MOKOSUITECLIENT_MENU_PLUGINS="Feature Plugins" COM_MOKOSUITECLIENT_MENU_PLUGINS="Feature Plugins"
@@ -215,15 +215,3 @@ INSERT IGNORE INTO `#__mokosuiteclient_retention_policies` (`id`, `content_type`
(4, 'inactive_users', 730, 'anonymize', 0, 'Anonymize users inactive for 2 years (disabled by default)'), (4, 'inactive_users', 730, 'anonymize', 0, 'Anonymize users inactive for 2 years (disabled by default)'),
(5, 'closed_tickets', 365, 'anonymize', 0, 'Anonymize closed tickets older than 1 year (disabled by default)'); (5, 'closed_tickets', 365, 'anonymize', 0, 'Anonymize closed tickets older than 1 year (disabled by default)');
-- ============================================================
-- License Cache — stores MokoGitea validation results
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuite_license_cache` (
`dlid_hash` CHAR(64) NOT NULL COMMENT 'SHA-256 of DLID (never store raw DLID)',
`response_data` TEXT NOT NULL COMMENT 'JSON validation response from MokoGitea',
`checked_at` DATETIME NOT NULL,
PRIMARY KEY (`dlid_hash`),
KEY `idx_checked` (`checked_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
File diff suppressed because it is too large Load Diff
@@ -26,7 +26,6 @@ class HtmlView extends BaseHtmlView
protected $wafChartData = []; protected $wafChartData = [];
protected $loginChartData = []; protected $loginChartData = [];
protected $mokoExtensions = []; protected $mokoExtensions = [];
public $supportPin = '';
public function display($tpl = null) public function display($tpl = null)
{ {
@@ -34,28 +33,6 @@ class HtmlView extends BaseHtmlView
$this->plugins = $model->getFeaturePlugins(); $this->plugins = $model->getFeaturePlugins();
$this->siteInfo = $model->getSiteInfo(); $this->siteInfo = $model->getSiteInfo();
// Daily support PIN from health token
try
{
$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'))
);
$token = (json_decode((string) $db->loadResult()))->health_api_token ?? '';
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(5); $this->recentLogins = $model->getRecentLogins(5);
$this->pendingUpdates = $model->getPendingUpdates(); $this->pendingUpdates = $model->getPendingUpdates();
$this->checkedOutItems = $model->getCheckedOutItems(); $this->checkedOutItems = $model->getCheckedOutItems();
@@ -0,0 +1,72 @@
<?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\View\Ticket;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $ticket;
protected $cannedResponses = [];
protected $statuses = [];
protected $priorities = [];
protected $customFields = [];
protected $fieldValues = [];
protected $attachments = [];
public function display($tpl = null)
{
$model = $this->getModel('Tickets');
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$this->ticket = $model->getTicket($id);
$this->cannedResponses = $model->getCannedResponses((int) ($this->ticket->category_id ?? 0));
$this->statuses = $model->getStatuses();
$this->priorities = $model->getPriorities();
// Load custom fields for this ticket's category
if ($this->ticket && $this->ticket->category_id)
{
$groups = $model->getFieldGroupsForCategory((int) $this->ticket->category_id);
$groupIds = array_column($groups, 'id');
$this->customFields = $model->getFieldsForGroups($groupIds);
$this->fieldValues = $model->getFieldValues($id);
}
// Load attachments
$this->attachments = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getForTicket($id);
if (!$this->ticket)
{
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
Factory::getApplication()->redirect('index.php?option=com_mokosuiteclient&view=tickets');
return;
}
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
$title = $this->ticket ? 'Ticket #' . $this->ticket->id . ' — ' . $this->ticket->subject : 'Ticket';
ToolbarHelper::title($title, 'headphones');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
}
}
@@ -0,0 +1,67 @@
<?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\View\Tickets;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $tickets = [];
protected $categories = [];
protected $statusCounts;
protected $overdue = [];
protected $atsAvailable = null;
protected $contacts = [];
protected $statuses = [];
protected $priorities = [];
protected $backendUsers = [];
protected $userGroups = [];
public function display($tpl = null)
{
$model = $this->getModel();
$app = Factory::getApplication();
$filters = [
'status_id' => $app->getInput()->getInt('filter_status', 0),
'priority_id' => $app->getInput()->getInt('filter_priority', 0),
'category_id' => $app->getInput()->getInt('filter_category', 0),
'contact_id' => $app->getInput()->getInt('filter_contact', 0),
];
$this->tickets = $model->getTickets($filters);
$this->categories = $model->getCategories();
$this->statuses = $model->getStatuses();
$this->priorities = $model->getPriorities();
$this->statusCounts = $model->getStatusCounts();
$this->overdue = $model->getOverdueTickets();
$this->atsAvailable = $model->checkAtsAvailable();
$this->contacts = $model->getContacts();
$this->backendUsers = $model->getBackendUsers();
$this->userGroups = $model->getUserGroups();
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_TICKETS_TITLE'), 'headphones');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient');
}
}
@@ -0,0 +1,41 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
*
* @package MokoSuiteClient
* @subpackage Component
*/
namespace Moko\Component\MokoSuiteClient\Administrator\View\Ticketsettings;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $statuses = [];
protected $priorities = [];
public function display($tpl = null)
{
$model = $this->getModel('Tickets');
$this->statuses = $model->getStatuses();
$this->priorities = $model->getPriorities();
$this->addToolbar();
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_TICKET_SETTINGS'), 'cog');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
}
}
@@ -0,0 +1 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -48,12 +48,6 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
<span class="mokosuiteclient-info-label">MokoSuiteClient</span> <span class="mokosuiteclient-info-label">MokoSuiteClient</span>
<span class="mokosuiteclient-info-value"><span class="badge bg-primary"><?php echo $this->escape($siteInfo->mokosuiteclient_version); ?></span></span> <span class="mokosuiteclient-info-value"><span class="badge bg-primary"><?php echo $this->escape($siteInfo->mokosuiteclient_version); ?></span></span>
</div> </div>
<?php if (!empty($this->supportPin)): ?>
<div class="mokosuiteclient-info-item">
<span class="mokosuiteclient-info-label">Support PIN</span>
<span class="mokosuiteclient-info-value"><span class="badge bg-dark" style="font-family:monospace;letter-spacing:0.08em;cursor:help;" title="Daily verification PIN — rotates at midnight UTC. Ask your provider for this code to verify identity."><span class="icon-key small me-1" aria-hidden="true"></span><?php echo $this->escape($this->supportPin); ?></span></span>
</div>
<?php endif; ?>
<div class="mokosuiteclient-info-item"> <div class="mokosuiteclient-info-item">
<span class="mokosuiteclient-info-label">Joomla</span> <span class="mokosuiteclient-info-label">Joomla</span>
<span class="mokosuiteclient-info-value"><span class="badge bg-secondary"><?php echo $this->escape($siteInfo->joomla_version); ?></span></span> <span class="mokosuiteclient-info-value"><span class="badge bg-secondary"><?php echo $this->escape($siteInfo->joomla_version); ?></span></span>
@@ -317,7 +311,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
<tr> <tr>
<td class="text-muted"><?php echo $this->escape(mb_substr($item->title, 0, 30)); ?></td> <td class="text-muted"><?php echo $this->escape(mb_substr($item->title, 0, 30)); ?></td>
<td class="text-muted"><?php echo $this->escape($item->username ?? ''); ?></td> <td class="text-muted"><?php echo $this->escape($item->username ?? ''); ?></td>
<td class="text-muted"><?php echo HTMLHelper::_('date', $item->checked_out_time, Text::_('DATE_FORMAT_LC4')); ?></td> <td class="text-muted"><?php echo HTMLHelper::_('date', $item->checked_out_time, 'M d H:i'); ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
@@ -348,7 +342,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
<tr> <tr>
<td class="text-muted"><code><?php echo $this->escape($block->ip); ?></code></td> <td class="text-muted"><code><?php echo $this->escape($block->ip); ?></code></td>
<td class="text-muted"><span class="badge bg-danger"><?php echo $this->escape($block->rule); ?></span></td> <td class="text-muted"><span class="badge bg-danger"><?php echo $this->escape($block->rule); ?></span></td>
<td class="text-muted"><?php echo HTMLHelper::_('date', $block->created, Text::_('DATE_FORMAT_LC4')); ?></td> <td class="text-muted"><?php echo HTMLHelper::_('date', $block->created, 'M d H:i'); ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
@@ -375,7 +369,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
<tr> <tr>
<td class="text-muted"><?php echo $this->escape($login->username ?? ''); ?></td> <td class="text-muted"><?php echo $this->escape($login->username ?? ''); ?></td>
<td class="text-muted"><code><?php echo $this->escape($login->ip_address ?? ''); ?></code></td> <td class="text-muted"><code><?php echo $this->escape($login->ip_address ?? ''); ?></code></td>
<td class="text-muted"><?php echo HTMLHelper::_('date', $login->log_date, Text::_('DATE_FORMAT_LC4')); ?></td> <td class="text-muted"><?php echo HTMLHelper::_('date', $login->log_date, 'M d H:i'); ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
@@ -3,7 +3,6 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory; use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper; use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route; use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session; use Joomla\CMS\Session\Session;
@@ -141,8 +140,8 @@ $typeBadge = [
<td><?php echo $this->escape($r->user_name ?? ''); ?><br><small class="text-muted"><?php echo $this->escape($r->user_email ?? ''); ?></small></td> <td><?php echo $this->escape($r->user_name ?? ''); ?><br><small class="text-muted"><?php echo $this->escape($r->user_email ?? ''); ?></small></td>
<td><span class="badge <?php echo $typeBadge[$r->type] ?? 'bg-secondary'; ?>"><?php echo ucfirst($r->type); ?></span></td> <td><span class="badge <?php echo $typeBadge[$r->type] ?? 'bg-secondary'; ?>"><?php echo ucfirst($r->type); ?></span></td>
<td><span class="badge <?php echo $statusBadge[$r->status] ?? 'bg-secondary'; ?>"><?php echo ucfirst($r->status); ?></span></td> <td><span class="badge <?php echo $statusBadge[$r->status] ?? 'bg-secondary'; ?>"><?php echo ucfirst($r->status); ?></span></td>
<td class="text-nowrap small"><?php echo HTMLHelper::_('date', $r->created, Text::_('DATE_FORMAT_LC2')); ?></td> <td class="text-nowrap small"><?php echo HTMLHelper::_('date', $r->created, 'M d, Y H:i'); ?></td>
<td class="text-nowrap small"><?php echo $r->processed ? HTMLHelper::_('date', $r->processed, Text::_('DATE_FORMAT_LC2')) : '—'; ?></td> <td class="text-nowrap small"><?php echo $r->processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?></td>
<td> <td>
<?php if ($r->status === 'pending'): ?> <?php if ($r->status === 'pending'): ?>
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
@@ -0,0 +1,364 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$t = $this->ticket;
$canned = $this->cannedResponses;
$token = Session::getFormToken();
$attachments = $this->attachments;
$downloadUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.downloadAttachment');
$uploadUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.uploadAttachment&format=json');
$deleteAttUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteAttachment&format=json');
// Group attachments by reply_id (null = ticket-level)
$attByReply = [];
foreach ($attachments as $att) {
$key = $att->reply_id ?? 0;
$attByReply[$key][] = $att;
}
$statuses = $this->statuses ?? [];
$priorities = $this->priorities ?? [];
?>
<div id="mokosuiteclient-ticket" class="row">
<!-- Left: conversation thread -->
<div class="col-12 col-xl-8">
<!-- Original ticket -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong><?php echo $this->escape($t->created_by_name); ?></strong>
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></small>
</div>
<span class="badge bg-dark">Original</span>
</div>
<div class="card-body">
<?php echo nl2br($this->escape($t->body)); ?>
<?php if (!empty($attByReply[0])): ?>
<hr>
<div class="small">
<strong>Attachments:</strong>
<?php foreach ($attByReply[0] as $att): ?>
<a href="<?php echo $downloadUrl . '&id=' . $att->id; ?>" class="d-inline-block me-3">
<span class="icon-download"></span> <?php echo $this->escape($att->filename); ?>
<span class="text-muted">(<?php echo \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::formatSize($att->filesize); ?>)</span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<!-- Replies -->
<?php foreach ($t->replies as $reply): ?>
<div class="card mb-3 <?php echo $reply->is_internal ? 'border-warning' : ''; ?>">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong><?php echo $this->escape($reply->user_name ?? 'System'); ?></strong>
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $reply->created, 'M d, Y H:i'); ?></small>
</div>
<?php if ($reply->is_internal): ?>
<span class="badge bg-warning text-dark">Internal Note</span>
<?php endif; ?>
</div>
<div class="card-body">
<?php echo nl2br($this->escape($reply->body)); ?>
<?php if (!empty($attByReply[$reply->id])): ?>
<hr>
<div class="small">
<strong>Attachments:</strong>
<?php foreach ($attByReply[$reply->id] as $att): ?>
<a href="<?php echo $downloadUrl . '&id=' . $att->id; ?>" class="d-inline-block me-3">
<span class="icon-download"></span> <?php echo $this->escape($att->filename); ?>
<span class="text-muted">(<?php echo \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::formatSize($att->filesize); ?>)</span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<!-- Reply form -->
<div class="card mb-3">
<div class="card-header"><strong>Reply</strong></div>
<div class="card-body">
<?php if (!empty($canned)): ?>
<div class="mb-2">
<select class="form-select form-select-sm" id="canned-select">
<option value="">Insert canned response...</option>
<?php foreach ($canned as $c): ?>
<option value="<?php echo $this->escape($c->body); ?>"><?php echo $this->escape($c->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<textarea id="reply-body" class="form-control mb-2" rows="5" placeholder="Type your reply..."></textarea>
<div class="mb-2">
<input type="file" id="reply-attachments" class="form-control form-control-sm" multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.zip">
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary" id="btn-reply"
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.addTicketReply&format=json'); ?>"
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>">
<span class="icon-reply"></span> Send Reply
</button>
<button type="button" class="btn btn-outline-warning" id="btn-internal"
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.addTicketReply&format=json'); ?>"
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>" data-internal="1">
<span class="icon-eye-slash"></span> Internal Note
</button>
</div>
</div>
</div>
</div>
<!-- Right: ticket metadata -->
<div class="col-12 col-xl-4">
<div class="card mb-3">
<div class="card-header"><strong>Details</strong></div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr><td class="text-muted">Status</td><td><span class="badge <?php echo $this->escape($t->status_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->status_title ?? $t->status); ?></span></td></tr>
<tr><td class="text-muted">Priority</td><td><span class="badge <?php echo $this->escape($t->priority_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->priority_title ?? $t->priority); ?></span></td></tr>
<tr><td class="text-muted">Category</td><td><?php echo $this->escape($t->category_title ?? '—'); ?></td></tr>
<tr><td class="text-muted">Created By</td><td><?php echo $this->escape($t->created_by_name); ?><br><small><?php echo $this->escape($t->created_by_email ?? ''); ?></small></td></tr>
<tr><td class="text-muted">Assigned To</td><td><?php
if (!empty($t->assignees)) {
foreach ($t->assignees as $a) {
$icon = $a->assignee_type === 'group' ? '<span class="icon-users"></span> ' : '<span class="icon-user"></span> ';
echo '<div>' . $icon . $this->escape($a->name) . '</div>';
}
} else {
echo '<em>Unassigned</em>';
}
?></td></tr>
<?php if ($t->contact_id): ?>
<tr><td class="text-muted">Contact</td><td>
<a href="<?php echo Route::_('index.php?option=com_contact&task=contact.edit&id=' . (int) $t->contact_id); ?>">
<?php echo $this->escape($t->contact_name ?? 'Contact #' . $t->contact_id); ?>
</a>
<?php if (!empty($t->contact_email)): ?><br><small><?php echo $this->escape($t->contact_email); ?></small><?php endif; ?>
<?php if (!empty($t->contact_phone)): ?><br><small><?php echo $this->escape($t->contact_phone); ?></small><?php endif; ?>
</td></tr>
<?php endif; ?>
<tr><td class="text-muted">Created</td><td><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></td></tr>
<?php if ($t->resolved): ?><tr><td class="text-muted">Resolved</td><td><?php echo HTMLHelper::_('date', $t->resolved, 'M d, Y H:i'); ?></td></tr><?php endif; ?>
<?php if ($t->closed): ?><tr><td class="text-muted">Closed</td><td><?php echo HTMLHelper::_('date', $t->closed, 'M d, Y H:i'); ?></td></tr><?php endif; ?>
<tr><td class="text-muted">Replies</td><td><?php echo $t->reply_count; ?></td></tr>
</table>
</div>
</div>
<!-- SLA -->
<?php if ($t->sla_response_due || $t->sla_resolution_due): ?>
<div class="card mb-3">
<div class="card-header"><strong>SLA</strong></div>
<div class="card-body">
<?php if ($t->sla_response_due): ?>
<div class="mb-2">
<small class="text-muted">Response Due</small><br>
<?php
$responseOverdue = !$t->sla_responded && strtotime($t->sla_response_due) < time();
?>
<span class="<?php echo $t->sla_responded ? 'text-success' : ($responseOverdue ? 'text-danger fw-bold' : ''); ?>">
<?php echo $t->sla_responded ? 'Responded' : HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?>
<?php echo $responseOverdue ? ' OVERDUE' : ''; ?>
</span>
</div>
<?php endif; ?>
<?php if ($t->sla_resolution_due): ?>
<div>
<small class="text-muted">Resolution Due</small><br>
<?php
$resolutionOverdue = !!empty($t->status_is_closed) && strtotime($t->sla_resolution_due) < time();
?>
<span class="<?php echo !empty($t->status_is_closed) ? 'text-success' : ($resolutionOverdue ? 'text-danger fw-bold' : ''); ?>">
<?php echo !empty($t->status_is_closed) ? 'Met' : HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?>
<?php echo $resolutionOverdue ? ' OVERDUE' : ''; ?>
</span>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<!-- Satisfaction Rating -->
<?php
$isClosed = in_array($t->status, ['resolved', 'closed'], true);
$hasRating = !empty($t->satisfaction_rating);
?>
<?php if ($hasRating): ?>
<div class="card mb-3">
<div class="card-header"><strong>Satisfaction</strong></div>
<div class="card-body text-center">
<div class="mb-1">
<?php for ($s = 1; $s <= 5; $s++): ?>
<span style="font-size:1.5rem;color:<?php echo $s <= $t->satisfaction_rating ? '#f5a623' : '#dee2e6'; ?>;">&#9733;</span>
<?php endfor; ?>
</div>
<div class="text-muted small"><?php echo $t->satisfaction_rating; ?>/5</div>
<?php if (!empty($t->satisfaction_feedback)): ?>
<p class="small mt-2 mb-0"><?php echo $this->escape($t->satisfaction_feedback); ?></p>
<?php endif; ?>
</div>
</div>
<?php elseif ($isClosed): ?>
<div class="card mb-3" id="rating-card">
<div class="card-header"><strong>Rate this Support</strong></div>
<div class="card-body text-center">
<div class="mb-2" id="star-rating">
<?php for ($s = 1; $s <= 5; $s++): ?>
<span class="star-btn" data-value="<?php echo $s; ?>" style="font-size:2rem;cursor:pointer;color:#dee2e6;">&#9733;</span>
<?php endfor; ?>
</div>
<textarea id="rating-feedback" class="form-control form-control-sm mb-2" rows="2" placeholder="Optional feedback..."></textarea>
<button type="button" class="btn btn-primary btn-sm" id="btn-rate"
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.rateTicket&format=json'); ?>"
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>" disabled>
Submit Rating
</button>
</div>
</div>
<?php endif; ?>
<!-- Status actions -->
<div class="card mb-3">
<div class="card-header"><strong>Actions</strong></div>
<div class="card-body d-grid gap-2">
<?php foreach ($statuses as $s): ?>
<?php if ((int) $s->id !== (int) $t->status_id): ?>
<button type="button" class="btn btn-sm btn-outline-<?php echo $s->is_closed ? 'danger' : 'secondary'; ?> btn-status"
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.updateTicketStatus&format=json'); ?>"
data-ticket="<?php echo $t->id; ?>" data-status="<?php echo $s->id; ?>" data-token="<?php echo $token; ?>">
<?php echo $this->escape($s->title); ?>
</button>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<!-- Custom Fields -->
<?php if (!empty($this->customFields)): ?>
<div class="card mb-3">
<div class="card-header"><strong>Custom Fields</strong></div>
<div class="card-body">
<table class="table table-sm mb-0">
<?php foreach ($this->customFields as $field): ?>
<tr>
<td class="text-muted"><?php echo $this->escape($field->title); ?></td>
<td><?php echo $this->escape($this->fieldValues[(int) $field->id] ?? '—'); ?></td>
</tr>
<?php endforeach; ?>
</table>
</div>
</div>
<?php endif; ?>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Canned response insert
var cannedSel = document.getElementById('canned-select');
if (cannedSel) {
cannedSel.addEventListener('change', function() {
if (this.value) { document.getElementById('reply-body').value = this.value; this.selectedIndex = 0; }
});
}
// Reply buttons (with attachment upload)
document.querySelectorAll('#btn-reply, #btn-internal').forEach(function(btn) {
btn.addEventListener('click', function() {
var body = document.getElementById('reply-body').value.trim();
var fileInput = document.getElementById('reply-attachments');
if (!body && (!fileInput || !fileInput.files.length)) return;
var el = this;
el.disabled = true;
var fd = new FormData();
fd.append('ticket_id', el.dataset.ticket);
fd.append('body', body || '(attachment)');
fd.append('is_internal', el.dataset.internal || '0');
fd.append(el.dataset.token, '1');
fetch(el.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({error:[d.message]}); el.disabled = false; return; }
// Upload attachments if any
if (fileInput && fileInput.files.length > 0) {
var afd = new FormData();
afd.append('ticket_id', el.dataset.ticket);
if (d.reply_id) afd.append('reply_id', d.reply_id);
for (var i = 0; i < fileInput.files.length; i++) {
afd.append('attachments[' + i + ']', fileInput.files[i]);
}
afd.append(el.dataset.token, '1');
fetch('<?php echo $uploadUrl; ?>', {method:'POST', body:afd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(){ location.reload(); });
} else {
location.reload();
}
})
.catch(function(){ el.disabled = false; });
});
});
// Status buttons
document.querySelectorAll('.btn-status').forEach(function(btn) {
btn.addEventListener('click', function() {
var el = this;
el.disabled = true;
var fd = new FormData();
fd.append('ticket_id', el.dataset.ticket);
fd.append('status', el.dataset.status);
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {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]}); })
.finally(function(){ el.disabled = false; });
});
});
// Star rating
var selectedRating = 0;
document.querySelectorAll('.star-btn').forEach(function(star) {
star.addEventListener('mouseenter', function() {
var val = parseInt(this.dataset.value);
document.querySelectorAll('.star-btn').forEach(function(s) {
s.style.color = parseInt(s.dataset.value) <= val ? '#f5a623' : '#dee2e6';
});
});
star.addEventListener('mouseleave', function() {
document.querySelectorAll('.star-btn').forEach(function(s) {
s.style.color = parseInt(s.dataset.value) <= selectedRating ? '#f5a623' : '#dee2e6';
});
});
star.addEventListener('click', function() {
selectedRating = parseInt(this.dataset.value);
document.getElementById('btn-rate').disabled = false;
});
});
var rateBtn = document.getElementById('btn-rate');
if (rateBtn) {
rateBtn.addEventListener('click', function() {
if (!selectedRating) return;
var el = this;
el.disabled = true;
var fd = new FormData();
fd.append('ticket_id', el.dataset.ticket);
fd.append('rating', selectedRating);
fd.append('feedback', document.getElementById('rating-feedback').value);
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {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]}); })
.finally(function(){ el.disabled = false; });
});
}
});
</script>
@@ -0,0 +1,317 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$tickets = $this->tickets;
$categories = $this->categories;
$statuses = $this->statuses;
$priorities = $this->priorities;
$counts = $this->statusCounts;
$overdue = $this->overdue;
$atsAvailable = $this->atsAvailable;
$token = Session::getFormToken();
?>
<div id="mokosuiteclient-tickets">
<!-- Status summary cards -->
<div class="row g-3 mb-4">
<?php foreach ($counts as $sc): ?>
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo (int) $sc->cnt; ?></span><small class="text-muted"><?php echo $this->escape($sc->title); ?></small></div></div>
<?php endforeach; ?>
<?php if (\count($overdue) > 0): ?>
<div class="col"><div class="card text-center p-2 border-danger"><span class="fw-bold fs-4 text-danger"><?php echo \count($overdue); ?></span><small class="text-danger">SLA Overdue</small></div></div>
<?php endif; ?>
</div>
<!-- New ticket + filters -->
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newTicketModal">
<span class="icon-plus"></span> New Ticket
</button>
<?php if ($atsAvailable): ?>
<button type="button" class="btn btn-outline-info" id="btn-import-ats"
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.importAts&format=json'); ?>"
data-token="<?php echo $token; ?>"
data-tickets="<?php echo $atsAvailable->tickets; ?>"
data-posts="<?php echo $atsAvailable->posts; ?>">
<span class="icon-upload"></span> Import from Akeeba (<?php echo $atsAvailable->tickets; ?> tickets, <?php echo $atsAvailable->posts; ?> posts)
</button>
<?php endif; ?>
</div>
<form method="get" class="d-flex gap-2">
<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 ($statuses as $s): ?>
<option value="<?php echo $s->id; ?>" <?php echo Factory::getApplication()->getInput()->getInt('filter_status') === (int) $s->id ? 'selected' : ''; ?>><?php echo $this->escape($s->title); ?></option>
<?php endforeach; ?>
</select>
<select name="filter_priority" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
<option value="">All Priorities</option>
<?php foreach ($priorities as $p): ?>
<option value="<?php echo $p->id; ?>" <?php echo Factory::getApplication()->getInput()->getInt('filter_priority') === (int) $p->id ? 'selected' : ''; ?>><?php echo $this->escape($p->title); ?></option>
<?php endforeach; ?>
</select>
</form>
</div>
<!-- Ticket table -->
<div class="card">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead>
<tr>
<th>#</th>
<th>Subject</th>
<th>Status</th>
<th>Priority</th>
<th>Category</th>
<th>Contact</th>
<th>Created By</th>
<th>Assigned To</th>
<th>Created</th>
<th>SLA</th>
</tr>
</thead>
<tbody>
<?php if (empty($tickets)): ?>
<tr><td colspan="10" class="text-center text-muted py-4">No tickets found.</td></tr>
<?php else: ?>
<?php foreach ($tickets as $t): ?>
<?php
$slaClass = '';
$now = time();
if ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now) $slaClass = 'table-danger';
elseif ($t->sla_resolution_due && strtotime($t->sla_resolution_due) < $now && empty($t->status_is_closed)) $slaClass = 'table-danger';
elseif ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now + 3600) $slaClass = 'table-warning';
?>
<tr class="<?php echo $slaClass; ?>">
<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 $this->escape(mb_substr($t->subject, 0, 60)); ?></a></td>
<td><span class="badge <?php echo $this->escape($t->status_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->status_title ?? $t->status); ?></span></td>
<td><span class="badge <?php echo $this->escape($t->priority_color ?? 'bg-secondary'); ?>"><?php echo $this->escape($t->priority_title ?? $t->priority); ?></span></td>
<td><?php echo $this->escape($t->category_title ?? '—'); ?></td>
<td><?php echo $t->contact_name ? '<a href="' . Route::_('index.php?option=com_contact&task=contact.edit&id=' . (int) $t->contact_id) . '">' . $this->escape($t->contact_name) . '</a>' : '—'; ?></td>
<td><?php echo $this->escape($t->created_by_name ?? ''); ?></td>
<td><?php
if (!empty($t->assignees)) {
$names = [];
foreach ($t->assignees as $a) {
$icon = $a->assignee_type === 'group' ? '<span class="icon-users"></span> ' : '';
$names[] = $icon . $this->escape($a->name);
}
echo implode(', ', $names);
} else {
echo '<em>Unassigned</em>';
}
?></td>
<td class="small"><?php echo HTMLHelper::_('date', $t->created, 'M d H:i'); ?></td>
<td class="small">
<?php if ($t->sla_response_due && !$t->sla_responded): ?>
<span title="Response due"><?php echo HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?></span>
<?php elseif ($t->sla_resolution_due): ?>
<span title="Resolution due"><?php echo HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?></span>
<?php else: ?>—<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- New Ticket Modal -->
<div class="modal fade" id="newTicketModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">New Ticket</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<!-- KB Search step -->
<div id="modal-kb-step">
<label class="form-label fw-bold">What's the issue?</label>
<div class="input-group mb-3">
<input type="text" id="modal-kb-search" class="form-control" placeholder="Describe your issue to search for existing answers...">
<button type="button" class="btn btn-outline-primary" id="modal-kb-btn"><span class="icon-search"></span></button>
</div>
<div id="modal-kb-results" class="list-group mb-3 d-none"></div>
<button type="button" class="btn btn-primary" id="modal-show-form">
<span class="icon-plus"></span> Create Ticket
</button>
</div>
<!-- Ticket form step (hidden initially) -->
<form id="modal-ticket-form" class="d-none" method="post" action="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.createTicket&format=json'); ?>">
<div class="mb-3">
<label class="form-label">Subject</label>
<input type="text" name="subject" id="modal-subject" class="form-control" required>
</div>
<div class="row mb-3">
<div class="col-md-4">
<label class="form-label">Category</label>
<select name="category_id" class="form-select">
<option value=""> Select </option>
<?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo $this->escape($cat->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Priority</label>
<select name="priority_id" class="form-select">
<?php foreach ($priorities as $p): ?>
<option value="<?php echo $p->id; ?>" <?php echo $p->is_default ? 'selected' : ''; ?>><?php echo $this->escape($p->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Contact</label>
<select name="contact_id" class="form-select">
<option value=""> None </option>
<?php foreach ($this->contacts as $contact): ?>
<option value="<?php echo $contact->id; ?>"><?php echo $this->escape($contact->name); ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Assign Users</label>
<select name="assign_users[]" class="form-select" multiple size="4">
<?php foreach ($this->backendUsers as $u): ?>
<option value="<?php echo $u->id; ?>"><?php echo $this->escape($u->name); ?></option>
<?php endforeach; ?>
</select>
<small class="text-muted">Hold Ctrl/Cmd to select multiple</small>
</div>
<div class="col-md-6">
<label class="form-label">Assign Groups</label>
<select name="assign_groups[]" class="form-select" multiple size="4">
<?php foreach ($this->userGroups as $g): ?>
<option value="<?php echo $g->id; ?>"><?php echo $this->escape($g->title); ?></option>
<?php endforeach; ?>
</select>
<small class="text-muted">Hold Ctrl/Cmd to select multiple</small>
</div>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea name="body" class="form-control" rows="6" required></textarea>
</div>
<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-plus"></span> Create Ticket</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
// Modal KB search
var modalSearch = document.getElementById('modal-kb-search');
var modalSearchBtn = document.getElementById('modal-kb-btn');
var modalResults = document.getElementById('modal-kb-results');
var modalShowForm = document.getElementById('modal-show-form');
var modalKbStep = document.getElementById('modal-kb-step');
var modalForm = document.getElementById('modal-ticket-form');
var modalSubject = document.getElementById('modal-subject');
function modalDoSearch() {
var q = modalSearch.value.trim();
if (q.length < 3) return;
fetch('<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.searchKb&format=json'); ?>&q=' + encodeURIComponent(q), {
headers: {'X-Requested-With': 'XMLHttpRequest'}
}).then(function(r){return r.json()}).then(function(d) {
modalResults.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);
}
modalResults.appendChild(a);
});
modalResults.classList.remove('d-none');
} else {
modalResults.classList.add('d-none');
}
});
}
if (modalSearchBtn) modalSearchBtn.addEventListener('click', modalDoSearch);
if (modalSearch) modalSearch.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); modalDoSearch(); } });
// Show ticket form
if (modalShowForm) {
modalShowForm.addEventListener('click', function() {
modalKbStep.classList.add('d-none');
modalForm.classList.remove('d-none');
if (modalSearch.value && !modalSubject.value) modalSubject.value = modalSearch.value;
modalSubject.focus();
});
}
// Submit ticket from modal
if (modalForm) {
modalForm.addEventListener('submit', function(e) {
e.preventDefault();
var form = this;
var fd = new FormData(form);
fetch(form.action, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { location.href = 'index.php?option=com_mokosuiteclient&view=ticket&id=' + d.id; }
else { Joomla.renderMessages({error:[d.message]}); }
});
});
}
// Reset modal on close
document.getElementById('newTicketModal').addEventListener('hidden.bs.modal', function() {
modalKbStep.classList.remove('d-none');
modalForm.classList.add('d-none');
modalResults.classList.add('d-none');
modalSearch.value = '';
modalForm.reset();
});
// ATS Import
var atsBtn = document.getElementById('btn-import-ats');
if (atsBtn) {
atsBtn.addEventListener('click', function() {
var el = this;
if (!confirm('Import ' + el.dataset.tickets + ' tickets and ' + el.dataset.posts + ' posts from Akeeba Ticket System? Duplicates will be skipped.')) return;
el.disabled = true;
el.textContent = ' Importing...';
var fd = new FormData();
fd.append(el.dataset.token, '1');
fetch(el.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]}); location.reload(); }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = 'Import Failed - Retry'; }
})
.catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; });
});
}
</script>
@@ -0,0 +1,203 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
*
* @package MokoSuiteClient
* @subpackage Component
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$token = Session::getFormToken();
$colorOptions = [
'bg-primary', 'bg-secondary', 'bg-success', 'bg-danger',
'bg-warning text-dark', 'bg-info text-dark', 'bg-dark', 'bg-light text-dark',
];
?>
<div class="row">
<!-- Statuses -->
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><span class="fa-solid fa-circle-dot"></span> Ticket Statuses</strong>
</div>
<div class="card-body p-0">
<table class="table table-striped mb-0">
<thead>
<tr>
<th>Title</th>
<th class="w-10 text-center">Color</th>
<th class="w-10 text-center">Default</th>
<th class="w-10 text-center">Closed?</th>
<th class="w-10 text-center">Order</th>
<th class="w-10 text-center">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($this->statuses as $s): ?>
<tr>
<td><?php echo $this->escape($s->title); ?> <small class="text-muted">(<?php echo $this->escape($s->alias); ?>)</small></td>
<td class="text-center"><span class="badge <?php echo $this->escape($s->color); ?>">&nbsp;&nbsp;&nbsp;</span></td>
<td class="text-center"><?php echo $s->is_default ? '<span class="badge bg-success">Yes</span>' : ''; ?></td>
<td class="text-center"><?php echo $s->is_closed ? '<span class="badge bg-dark">Closed</span>' : ''; ?></td>
<td class="text-center"><?php echo (int) $s->ordering; ?></td>
<td class="text-center">
<button class="btn btn-sm btn-outline-primary" onclick="editStatus(<?php echo htmlspecialchars(json_encode($s)); ?>)">
<span class="icon-pencil"></span>
</button>
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.deleteStatus&id=' . $s->id . '&' . $token . '=1'); ?>"
class="btn btn-sm btn-outline-danger"
onclick="return confirm('Delete this status?')">
<span class="icon-trash"></span>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="card-footer">
<form method="post" action="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.saveStatus'); ?>" id="statusForm" class="row g-2 align-items-end">
<input type="hidden" name="id" id="status-id" value="0">
<div class="col-md-3">
<label class="form-label small">Title</label>
<input type="text" name="title" id="status-title" class="form-control form-control-sm" required>
</div>
<div class="col-md-2">
<label class="form-label small">Alias</label>
<input type="text" name="alias" id="status-alias" class="form-control form-control-sm">
</div>
<div class="col-md-2">
<label class="form-label small">Color</label>
<select name="color" id="status-color" class="form-select form-select-sm">
<?php foreach ($colorOptions as $c): ?>
<option value="<?php echo $c; ?>"><?php echo $c; ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-1">
<label class="form-label small">Order</label>
<input type="number" name="ordering" id="status-ordering" class="form-control form-control-sm" value="0">
</div>
<div class="col-md-1 text-center">
<label class="form-label small">Default</label>
<input type="checkbox" name="is_default" id="status-default" value="1" class="form-check-input">
</div>
<div class="col-md-1 text-center">
<label class="form-label small">Closed</label>
<input type="checkbox" name="is_closed" id="status-closed" value="1" class="form-check-input">
</div>
<div class="col-md-2">
<input type="hidden" name="<?php echo $token; ?>" value="1">
<button type="submit" class="btn btn-sm btn-primary w-100" id="status-btn">Add</button>
</div>
</form>
</div>
</div>
</div>
<!-- Priorities -->
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><span class="fa-solid fa-flag"></span> Ticket Priorities</strong>
</div>
<div class="card-body p-0">
<table class="table table-striped mb-0">
<thead>
<tr>
<th>Title</th>
<th class="w-10 text-center">Color</th>
<th class="w-10 text-center">Default</th>
<th class="w-10 text-center">Order</th>
<th class="w-10 text-center">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($this->priorities as $p): ?>
<tr>
<td><?php echo $this->escape($p->title); ?> <small class="text-muted">(<?php echo $this->escape($p->alias); ?>)</small></td>
<td class="text-center"><span class="badge <?php echo $this->escape($p->color); ?>">&nbsp;&nbsp;&nbsp;</span></td>
<td class="text-center"><?php echo $p->is_default ? '<span class="badge bg-success">Yes</span>' : ''; ?></td>
<td class="text-center"><?php echo (int) $p->ordering; ?></td>
<td class="text-center">
<button class="btn btn-sm btn-outline-primary" onclick="editPriority(<?php echo htmlspecialchars(json_encode($p)); ?>)">
<span class="icon-pencil"></span>
</button>
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.deletePriority&id=' . $p->id . '&' . $token . '=1'); ?>"
class="btn btn-sm btn-outline-danger"
onclick="return confirm('Delete this priority?')">
<span class="icon-trash"></span>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="card-footer">
<form method="post" action="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.savePriority'); ?>" id="priorityForm" class="row g-2 align-items-end">
<input type="hidden" name="id" id="priority-id" value="0">
<div class="col-md-3">
<label class="form-label small">Title</label>
<input type="text" name="title" id="priority-title" class="form-control form-control-sm" required>
</div>
<div class="col-md-2">
<label class="form-label small">Alias</label>
<input type="text" name="alias" id="priority-alias" class="form-control form-control-sm">
</div>
<div class="col-md-2">
<label class="form-label small">Color</label>
<select name="color" id="priority-color" class="form-select form-select-sm">
<?php foreach ($colorOptions as $c): ?>
<option value="<?php echo $c; ?>"><?php echo $c; ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-1">
<label class="form-label small">Order</label>
<input type="number" name="ordering" id="priority-ordering" class="form-control form-control-sm" value="0">
</div>
<div class="col-md-1 text-center">
<label class="form-label small">Default</label>
<input type="checkbox" name="is_default" id="priority-default" value="1" class="form-check-input">
</div>
<div class="col-md-3">
<input type="hidden" name="<?php echo $token; ?>" value="1">
<button type="submit" class="btn btn-sm btn-primary w-100" id="priority-btn">Add</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
function editStatus(s) {
document.getElementById('status-id').value = s.id;
document.getElementById('status-title').value = s.title;
document.getElementById('status-alias').value = s.alias;
document.getElementById('status-color').value = s.color;
document.getElementById('status-ordering').value = s.ordering;
document.getElementById('status-default').checked = !!parseInt(s.is_default);
document.getElementById('status-closed').checked = !!parseInt(s.is_closed);
document.getElementById('status-btn').textContent = 'Update';
}
function editPriority(p) {
document.getElementById('priority-id').value = p.id;
document.getElementById('priority-title').value = p.title;
document.getElementById('priority-alias').value = p.alias;
document.getElementById('priority-color').value = p.color;
document.getElementById('priority-ordering').value = p.ordering;
document.getElementById('priority-default').checked = !!parseInt(p.is_default);
document.getElementById('priority-btn').textContent = 'Update';
}
</script>
@@ -0,0 +1 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -3,7 +3,6 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory; use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper; use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route; use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session; use Joomla\CMS\Session\Session;
@@ -99,7 +98,7 @@ $ruleBadge = [
<?php else: ?> <?php else: ?>
<?php foreach ($logs as $log): ?> <?php foreach ($logs as $log): ?>
<tr> <tr>
<td class="text-nowrap small"><?php echo HTMLHelper::_('date', $log->created, Text::_('DATE_FORMAT_LC4')); ?></td> <td class="text-nowrap small"><?php echo HTMLHelper::_('date', $log->created, 'M d H:i:s'); ?></td>
<td><code><?php echo htmlspecialchars($log->ip); ?></code></td> <td><code><?php echo htmlspecialchars($log->ip); ?></code></td>
<td><span class="badge <?php echo $ruleBadge[$log->rule] ?? 'bg-secondary'; ?>"><?php echo htmlspecialchars($log->rule); ?></span></td> <td><span class="badge <?php echo $ruleBadge[$log->rule] ?? 'bg-secondary'; ?>"><?php echo htmlspecialchars($log->rule); ?></span></td>
<td class="small" style="max-width:250px;overflow:hidden;text-overflow:ellipsis" title="<?php echo htmlspecialchars($log->uri); ?>"><?php echo htmlspecialchars(mb_substr($log->uri, 0, 60)); ?></td> <td class="small" style="max-width:250px;overflow:hidden;text-overflow:ellipsis" title="<?php echo htmlspecialchars($log->uri); ?>"><?php echo htmlspecialchars(mb_substr($log->uri, 0, 60)); ?></td>
@@ -149,7 +148,7 @@ $ruleBadge = [
<tr> <tr>
<td><code class="small"><?php echo htmlspecialchars($tip->ip); ?></code></td> <td><code class="small"><?php echo htmlspecialchars($tip->ip); ?></code></td>
<td class="fw-bold"><?php echo $tip->cnt; ?></td> <td class="fw-bold"><?php echo $tip->cnt; ?></td>
<td class="small text-nowrap"><?php echo HTMLHelper::_('date', $tip->last_seen, Text::_('DATE_FORMAT_LC4')); ?></td> <td class="small text-nowrap"><?php echo HTMLHelper::_('date', $tip->last_seen, 'M d'); ?></td>
<td> <td>
<button type="button" class="btn btn-sm btn-outline-danger btn-ban-ip" data-ip="<?php echo htmlspecialchars($tip->ip); ?>" <button type="button" class="btn btn-sm btn-outline-danger btn-ban-ip" data-ip="<?php echo htmlspecialchars($tip->ip); ?>"
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.banIpFromLog&format=json'); ?>" data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.banIpFromLog&format=json'); ?>"
@@ -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.47.08</version> <version>02.46.99</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>
@@ -7,9 +7,9 @@
<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.47.08</version> <version>02.46.99</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\MokoSuiteCache</namespace>
<files> <files>
<folder module="mod_mokosuiteclient_cache">services</folder> <folder module="mod_mokosuiteclient_cache">services</folder>
@@ -4,15 +4,11 @@ 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\Uri\Uri;
class Dispatcher extends AbstractModuleDispatcher class Dispatcher extends AbstractModuleDispatcher
{ {
protected function getLayoutData() protected function getLayoutData()
{ {
$data = parent::getLayoutData(); return parent::getLayoutData();
$data['domain'] = parse_url(Uri::root(), PHP_URL_HOST) ?: '';
return $data;
} }
} }
@@ -13,7 +13,6 @@ use Joomla\CMS\Session\Session;
$token = Session::getFormToken(); $token = Session::getFormToken();
$cacheUrl = 'index.php?option=com_mokosuiteclient&task=clearCache&format=json'; $cacheUrl = 'index.php?option=com_mokosuiteclient&task=clearCache&format=json';
$tempUrl = 'index.php?option=com_mokosuiteclient&task=clearTemp&format=json'; $tempUrl = 'index.php?option=com_mokosuiteclient&task=clearTemp&format=json';
$domain = $domain ?? '';
?> ?>
<style> <style>
@@ -22,15 +21,9 @@ $domain = $domain ?? '';
.mokosuiteclient-cleaner-btn { cursor:pointer; padding:0.2rem 0.5rem; font-size:0.8rem; border-radius:3px; text-decoration:none; color:var(--template-text-dark,#495057); transition:background 0.15s; white-space:nowrap; } .mokosuiteclient-cleaner-btn { cursor:pointer; padding:0.2rem 0.5rem; font-size:0.8rem; border-radius:3px; text-decoration:none; color:var(--template-text-dark,#495057); transition:background 0.15s; white-space:nowrap; }
.mokosuiteclient-cleaner-btn:hover { background:rgba(0,0,0,0.08); color:var(--template-text-dark,#212529); text-decoration:none; } .mokosuiteclient-cleaner-btn:hover { background:rgba(0,0,0,0.08); color:var(--template-text-dark,#212529); text-decoration:none; }
.mokosuiteclient-cleaner-sep { color:var(--template-text-dark,#adb5bd); padding:0 0.1rem; font-size:0.8rem; } .mokosuiteclient-cleaner-sep { color:var(--template-text-dark,#adb5bd); padding:0 0.1rem; font-size:0.8rem; }
.mokosuiteclient-domain { font-family:monospace; font-size:0.75rem; color:var(--template-text-dark,#6c757d); cursor:pointer; padding:0.15rem 0.4rem; border-radius:3px; transition:background 0.15s; }
.mokosuiteclient-domain:hover { background:rgba(0,0,0,0.06); }
</style> </style>
<div class="header-item-content mokosuiteclient-cleaner"> <div class="header-item-content mokosuiteclient-cleaner">
<?php if ($domain): ?>
<span class="mokosuiteclient-domain" id="mokosuiteclient-domain" title="Support key — click to copy"><?php echo htmlspecialchars($domain); ?></span>
<span class="mokosuiteclient-cleaner-sep">|</span>
<?php endif; ?>
<span class="mokosuiteclient-cleaner-label">Clear:</span> <span class="mokosuiteclient-cleaner-label">Clear:</span>
<a href="#" class="mokosuiteclient-cleaner-btn" id="mokosuiteclient-clear-cache" title="Clear all Joomla cache"> <a href="#" class="mokosuiteclient-cleaner-btn" id="mokosuiteclient-clear-cache" title="Clear all Joomla cache">
<span class="icon-bolt" aria-hidden="true" id="mokosuiteclient-cache-icon"></span> Cache <span class="icon-bolt" aria-hidden="true" id="mokosuiteclient-cache-icon"></span> Cache
@@ -92,17 +85,5 @@ 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>
@@ -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.47.08</version> <version>02.46.99</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,9 +7,9 @@
<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.47.08</version> <version>02.46.99</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\MokoSuiteCpanel</namespace>
<files> <files>
<folder module="mod_mokosuiteclient_cpanel">services</folder> <folder module="mod_mokosuiteclient_cpanel">services</folder>
@@ -28,6 +28,14 @@
label="MOD_MOKOSUITECLIENT_CPANEL_FIELDSET_DISPLAY" label="MOD_MOKOSUITECLIENT_CPANEL_FIELDSET_DISPLAY"
description="MOD_MOKOSUITECLIENT_CPANEL_FIELDSET_DISPLAY_DESC"> description="MOD_MOKOSUITECLIENT_CPANEL_FIELDSET_DISPLAY_DESC">
<field name="collapsed" type="radio" default="1"
label="MOD_MOKOSUITECLIENT_CPANEL_COLLAPSED_LABEL"
description="MOD_MOKOSUITECLIENT_CPANEL_COLLAPSED_DESC"
layout="joomla.form.field.radio.switcher">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="show_health" type="radio" default="1" <field name="show_health" type="radio" default="1"
label="MOD_MOKOSUITECLIENT_CPANEL_SHOW_HEALTH_LABEL" label="MOD_MOKOSUITECLIENT_CPANEL_SHOW_HEALTH_LABEL"
layout="joomla.form.field.radio.switcher"> layout="joomla.form.field.radio.switcher">
@@ -47,7 +47,7 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
$data['currentIp'] = $helper->getCurrentIp(); $data['currentIp'] = $helper->getCurrentIp();
$data['ssl'] = $helper->getSslStatus(); $data['ssl'] = $helper->getSslStatus();
// Daily support PIN derived from health token + today's date (UTC) // Support PIN derived from health token
$data['supportPin'] = ''; $data['supportPin'] = '';
try try
@@ -65,9 +65,7 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
if (!empty($token)) if (!empty($token))
{ {
$date = gmdate('Y-m-d'); $data['supportPin'] = 'MOKO-' . strtoupper(substr($token, 0, 4) . '-' . substr($token, 4, 4));
$hash = hash_hmac('sha256', $date, $token);
$data['supportPin'] = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4));
} }
} }
catch (\Throwable $e) {} catch (\Throwable $e) {}
@@ -22,7 +22,7 @@ $healthOk = $healthOk ?? true;
$counts = $counts ?? (object) ['articles' => 0, 'users' => 0, 'extensions' => 0, 'updates' => 0]; $counts = $counts ?? (object) ['articles' => 0, 'users' => 0, 'extensions' => 0, 'updates' => 0];
$disk = $disk ?? (object) ['free_mb' => null, 'total_mb' => null]; $disk = $disk ?? (object) ['free_mb' => null, 'total_mb' => null];
$currentIp = $currentIp ?? ''; $currentIp = $currentIp ?? '';
$collapsed = true; $collapsed = $params->get('collapsed', 0);
$showHealth = $params->get('show_health', 1); $showHealth = $params->get('show_health', 1);
$showStats = $params->get('show_stats', 1); $showStats = $params->get('show_stats', 1);
$showDisk = $params->get('show_disk', 1); $showDisk = $params->get('show_disk', 1);
@@ -44,13 +44,10 @@ foreach ($plugins as $p)
} }
$labels = [ $labels = [
'mokosuiteclient' => 'Core Engine', 'mokosuiteclient' => 'Core',
'mokosuiteclient_firewall' => 'Web Firewall', 'mokosuiteclient_firewall' => 'Firewall',
'mokosuiteclient_tenant' => 'Tenant Guard', 'mokosuiteclient_tenant' => 'Tenant',
'mokosuiteclient_devtools' => 'Dev Tools', 'mokosuiteclient_devtools' => 'DevTools',
'mokosuiteclient_offline' => 'Offline Bypass',
'mokosuiteclient_dbip' => 'GeoIP Lookup',
'mokosuiteclient_license' => 'License Manager',
]; ];
$diskPct = ($disk->total_mb && $disk->total_mb > 0) $diskPct = ($disk->total_mb && $disk->total_mb > 0)
@@ -66,7 +63,7 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
<span class="fa-solid fa-caret-<?php echo $collapsed ? 'right' : 'down'; ?>" aria-hidden="true" id="mokosuiteclient-cpanel-caret"></span> <span class="fa-solid fa-caret-<?php echo $collapsed ? 'right' : 'down'; ?>" aria-hidden="true" id="mokosuiteclient-cpanel-caret"></span>
</button> </button>
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1.25rem;color:#1a2744"></span> <span class="icon-shield-alt" aria-hidden="true" style="font-size:1.25rem;color:#1a2744"></span>
<strong>MokoSuite</strong> <strong>MokoSuiteClient</strong>
<span class="badge bg-primary"><?php echo htmlspecialchars($siteInfo->mokosuiteclient_version ?? ''); ?></span> <span class="badge bg-primary"><?php echo htmlspecialchars($siteInfo->mokosuiteclient_version ?? ''); ?></span>
<?php if (!empty($supportPin)): ?> <?php if (!empty($supportPin)): ?>
<span class="badge bg-dark" style="font-family:monospace;letter-spacing:0.08em;" title="Support PIN"><?php echo htmlspecialchars($supportPin); ?></span> <span class="badge bg-dark" style="font-family:monospace;letter-spacing:0.08em;" title="Support PIN"><?php echo htmlspecialchars($supportPin); ?></span>
@@ -78,8 +75,8 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
<span class="badge bg-danger">Offline</span> <span class="badge bg-danger">Offline</span>
<?php endif; ?> <?php endif; ?>
<?php if (($counts->moko_updates ?? 0) > 0): ?> <?php if (($counts->moko_updates ?? 0) > 0): ?>
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="badge bg-info text-decoration-none" title="MokoSuite updates available"> <a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="badge bg-info text-decoration-none" title="MokoSuiteClient updates available">
<span class="icon-upload" aria-hidden="true"></span> <?php echo $counts->moko_updates; ?> MokoSuite update<?php echo $counts->moko_updates > 1 ? 's' : ''; ?> <span class="icon-upload" aria-hidden="true"></span> <?php echo $counts->moko_updates; ?> MokoSuiteClient update<?php echo $counts->moko_updates > 1 ? 's' : ''; ?>
</a> </a>
<?php endif; ?> <?php endif; ?>
<?php if ($counts->updates > 0 && $counts->updates !== ($counts->moko_updates ?? 0)): ?> <?php if ($counts->updates > 0 && $counts->updates !== ($counts->moko_updates ?? 0)): ?>
@@ -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.47.08</version> <version>02.46.99</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>
@@ -3,7 +3,7 @@
* MokoSuiteClient Admin Sidebar Menu * MokoSuiteClient Admin Sidebar Menu
* *
* Each installed Moko component gets its own top-level collapsible section. * Each installed Moko component gets its own top-level collapsible section.
* com_mokosuitehq is always pinned first. com_mokosuiteclient uses static views * com_mokosuiteclienthq is always pinned first. com_mokosuiteclient uses static views
* as children. All other components auto-discover their submenu items. * as children. All other components auto-discover their submenu items.
*/ */
@@ -20,6 +20,7 @@ $currentView = $app->getInput()->get('view', '');
// ── Static views for com_mokosuiteclient ────────────────────────────────── // ── Static views for com_mokosuiteclient ──────────────────────────────────
$mokosuiteclientStaticViews = [ $mokosuiteclientStaticViews = [
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuiteclient'], ['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuiteclient'],
['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokosuiteclient&view=tickets'],
['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'],
['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'],
['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokosuiteclient&view=privacy'], ['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokosuiteclient&view=privacy'],
@@ -100,7 +101,7 @@ else
// com_mokosuiteclient not in admin menu — add it manually // com_mokosuiteclient not in admin menu — add it manually
$mokoComponents['com_mokosuiteclient'] = [ $mokoComponents['com_mokosuiteclient'] = [
'id' => 0, 'id' => 0,
'title' => 'MokoSuite', 'title' => 'MokoSuiteClient',
'link' => 'index.php?option=com_mokosuiteclient', 'link' => 'index.php?option=com_mokosuiteclient',
'icon' => 'icon-shield-alt', 'icon' => 'icon-shield-alt',
'element' => 'com_mokosuiteclient', 'element' => 'com_mokosuiteclient',
@@ -108,37 +109,16 @@ else
]; ];
} }
// ── Sort: HQ first, Client second, then alphabetical ───────── // ── Sort: com_mokosuiteclienthq first, then alphabetical by title ─────────
$hq = null; $hq = null;
$client = null;
$rest = []; $rest = [];
foreach ($mokoComponents as $key => $comp) foreach ($mokoComponents as $key => $comp)
{ {
// Shorten display titles: if ($key === 'com_mokosuiteclienthq')
// MokoSuiteClient → MokoSuite, MokoSuiteHQ → MokoHQ
// Everything else: MokoSuiteBackup → Backup, MokoSuiteOpenGraph → OpenGraph
if ($key === 'com_mokosuiteclient')
{
$comp['title'] = 'MokoSuite';
}
elseif ($key === 'com_mokosuitehq')
{
$comp['title'] = preg_replace('/^MokoSuite/i', 'Moko', $comp['title']);
}
else
{
$comp['title'] = preg_replace('/^MokoSuite\s*/i', '', $comp['title']);
}
if ($key === 'com_mokosuitehq')
{ {
$hq = $comp; $hq = $comp;
} }
elseif ($key === 'com_mokosuiteclient')
{
$client = $comp;
}
else else
{ {
$rest[$key] = $comp; $rest[$key] = $comp;
@@ -152,10 +132,6 @@ if ($hq !== null)
{ {
$sorted[] = $hq; $sorted[] = $hq;
} }
if ($client !== null)
{
$sorted[] = $client;
}
foreach ($rest as $comp) foreach ($rest as $comp)
{ {
$sorted[] = $comp; $sorted[] = $comp;
@@ -163,7 +139,8 @@ foreach ($rest as $comp)
?> ?>
<style> <style>
.sidebar-wrapper { padding-right: 0.5rem; } .sidebar-wrapper .mokosuiteclient-ext-item > a { padding-inline-start: 1.5rem; }
.sidebar-wrapper .mokosuiteclient-ext-child > a { padding-inline-start: 2.5rem; }
</style> </style>
<ul class="nav flex-column main-nav"> <ul class="nav flex-column main-nav">
@@ -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.47.08 * VERSION: 02.46.99
* PATH: /src/Extension/MokoSuiteClient.php * PATH: /src/Extension/MokoSuiteClient.php
* NOTE: Core system plugin for MokoSuiteClient admin tools suite * NOTE: Core system plugin for MokoSuiteClient admin tools suite
*/ */
@@ -968,21 +968,13 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
if (!in_array($akTable, $tables)) if (!in_array($akTable, $tables))
{ {
// Check for MokoSuiteBackup instead return [
if (!in_array($prefix . 'mokosuitebackup_records', $tables)) 'status' => 'ok',
{ 'installed' => false,
return [ ];
'status' => 'ok',
'installed' => false,
'message' => 'No backup solution installed (Akeeba Backup or MokoSuiteBackup)',
];
}
// MokoSuiteBackup is installed — query its table
return $this->checkMokoSuiteBackup($db);
} }
// Get the most recent Akeeba Backup // Get the most recent backup
$query = $db->getQuery(true) $query = $db->getQuery(true)
->select([ ->select([
$db->quoteName('id'), $db->quoteName('id'),
@@ -1058,53 +1050,13 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
} }
catch (\Exception $e) catch (\Exception $e)
{ {
\Joomla\CMS\Log\Log::add('Backup check failed: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokosuiteclient');
return [ return [
'status' => 'error', 'status' => 'ok',
'message' => 'Backup check failed: ' . $e->getMessage(), 'installed' => false,
]; ];
} }
} }
/**
* Query MokoSuiteBackup tables for backup status.
*/
protected function checkMokoSuiteBackup($db): array
{
$db->setQuery(
$db->getQuery(true)
->select(['id', 'description', 'status', 'backupstart', 'backupend', 'total_size'])
->from($db->quoteName('#__mokosuitebackup_records'))
->order($db->quoteName('id') . ' DESC'),
0, 1
);
$latest = $db->loadObject();
if (!$latest)
{
return ['status' => 'degraded', 'installed' => true, 'message' => 'MokoSuiteBackup installed but no backups found'];
}
$daysSince = 999;
if (!empty($latest->backupstart) && $latest->backupstart !== '0000-00-00 00:00:00')
{
$daysSince = (int) ((time() - strtotime($latest->backupstart)) / 86400);
}
$status = ($latest->status === 'complete' && $daysSince <= 7) ? 'ok' : 'degraded';
return [
'status' => $status,
'installed' => true,
'last_backup' => $latest->backupstart,
'last_status' => $latest->status,
'days_since' => $daysSince,
'message' => 'MokoSuiteBackup: last backup ' . $daysSince . 'd ago (' . $latest->status . ')',
];
}
/** /**
* Check Admin Tools status WAF status, security exceptions. * Check Admin Tools status WAF status, security exceptions.
* *
@@ -1131,7 +1083,6 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
return [ return [
'status' => 'ok', 'status' => 'ok',
'installed' => false, 'installed' => false,
'message' => 'Admin Tools is not installed',
]; ];
} }
@@ -1238,7 +1189,7 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
} }
catch (\Exception $e) catch (\Exception $e)
{ {
return ['status' => 'error', 'message' => 'SSL check failed: ' . $e->getMessage()]; return ['status' => 'ok', 'https' => false];
} }
} }
@@ -1258,7 +1209,7 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
if (!in_array($prefix . 'scheduler_tasks', $tables)) if (!in_array($prefix . 'scheduler_tasks', $tables))
{ {
return ['status' => 'ok', 'available' => false, 'message' => 'Task Scheduler not available']; return ['status' => 'ok', 'available' => false];
} }
$db->setQuery( $db->setQuery(
@@ -1323,7 +1274,7 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
} }
catch (\Exception $e) catch (\Exception $e)
{ {
return ['status' => 'error', 'message' => 'Scheduler check failed: ' . $e->getMessage()]; return ['status' => 'ok', 'available' => false];
} }
} }
@@ -1750,7 +1701,6 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
return [ return [
'status' => $status, 'status' => $status,
'message' => $issues ? implode('; ', $issues) : 'All configuration settings are optimal',
'debug' => $debug, 'debug' => $debug,
'error_report' => $errorReport, 'error_report' => $errorReport,
'gzip' => $gzip, 'gzip' => $gzip,
@@ -1759,6 +1709,7 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
'force_ssl' => $forceSSL, 'force_ssl' => $forceSSL,
'caching' => $caching, 'caching' => $caching,
'lifetime' => $lifetime, 'lifetime' => $lifetime,
'issues' => $issues ?: null,
]; ];
} }
@@ -1835,36 +1786,6 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
*/ */
protected function getDevAliasDomain(): string protected function getDevAliasDomain(): string
{ {
// Check devtools plugin params for custom dev domain
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient_devtools'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
);
$devParams = json_decode((string) $db->loadResult());
if ($devParams && ($devParams->dev_domain_enabled ?? '1') === '0')
{
return '';
}
if (!empty($devParams->dev_domain))
{
return trim($devParams->dev_domain);
}
}
catch (\Throwable $e)
{
// Fall through to default
}
// Default: dev.{primary_domain}
$primary = $this->getPrimaryHost(); $primary = $this->getPrimaryHost();
return !empty($primary) ? 'dev.' . $primary : ''; return !empty($primary) ? 'dev.' . $primary : '';
@@ -1879,16 +1800,10 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
*/ */
protected function isDevAlias(): bool protected function isDevAlias(): bool
{ {
$devAlias = $this->getDevAliasConfig();
if ($devAlias === null)
{
return false;
}
$currentHost = $_SERVER['HTTP_HOST'] ?? ''; $currentHost = $_SERVER['HTTP_HOST'] ?? '';
$devDomain = $this->getDevAliasDomain();
return !empty($currentHost) && strcasecmp($currentHost, $devAlias['domain']) === 0; return !empty($devDomain) && strcasecmp($currentHost, $devDomain) === 0;
} }
protected function getCurrentAlias() protected function getCurrentAlias()
@@ -1973,51 +1888,32 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
*/ */
protected function handleSiteAlias() protected function handleSiteAlias()
{ {
$devAlias = $this->getDevAliasConfig(); // The dev alias (dev.{primary_domain}) always bypasses offline mode
if ($this->isDevAlias())
if ($devAlias === null)
{
return;
}
// Check if current request is on the dev domain
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
if (empty($currentHost) || strcasecmp($currentHost, $devAlias['domain']) !== 0)
{
return;
}
// Bypass offline mode if enabled
if (!empty($devAlias['bypass_offline']))
{ {
$this->app->getConfig()->set('offline', 0); $this->app->getConfig()->set('offline', 0);
return;
} }
} }
/** /**
* Inject robots meta tag for alias domains. * Inject robots meta tag for alias domains.
*
* @param \Joomla\CMS\Document\HtmlDocument $doc Document object
*
* @return void
*
* @since 02.01.43
*/ */
protected function injectAliasRobots($doc) protected function injectAliasRobots($doc)
{ {
$devAlias = $this->getDevAliasConfig(); // Always noindex/nofollow on the dev alias domain
if ($this->isDevAlias())
if ($devAlias === null)
{ {
return; $doc->setMetaData('robots', 'noindex, nofollow');
} }
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
if (empty($currentHost) || strcasecmp($currentHost, $devAlias['domain']) !== 0)
{
return;
}
// Set robots directive from devtools config
$robots = $devAlias['robots'] ?? 'noindex, nofollow';
$doc->setMetaData('robots', $robots);
// Inject canonical URL pointing to the primary domain // Inject canonical URL pointing to the primary domain
$primaryHost = $this->getPrimaryHost(); $primaryHost = $this->getPrimaryHost();
$currentUri = Uri::getInstance(); $currentUri = Uri::getInstance();
@@ -2025,98 +1921,6 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
$doc->addHeadLink($canonical, 'canonical'); $doc->addHeadLink($canonical, 'canonical');
} }
/**
* Get the alias config matching the current request domain.
*
* Reads the site_aliases subform from DevTools plugin params.
* Each entry has: domain, offline_bypass, robots, label.
* Also auto-includes dev.{primary_domain} if no aliases are configured.
*
* @return array|null ['domain' => '...', 'bypass_offline' => bool, 'robots' => '...', 'label' => '...'] or null
*/
private function getDevAliasConfig(): ?array
{
static $config = false;
if ($config !== false)
{
return $config;
}
$config = null;
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
if (empty($currentHost))
{
return null;
}
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient_devtools'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
);
$devParams = json_decode((string) $db->loadResult(), true) ?: [];
$aliases = $devParams['site_aliases'] ?? [];
// Normalize — Joomla subform stores as object or array
if (\is_object($aliases))
{
$aliases = (array) $aliases;
}
// Check each configured alias against current host
foreach ($aliases as $entry)
{
$entry = (array) $entry;
$domain = trim($entry['domain'] ?? '');
if (!empty($domain) && strcasecmp($currentHost, $domain) === 0)
{
$config = [
'domain' => $domain,
'bypass_offline' => ($entry['offline_bypass'] ?? '1') === '1',
'robots' => $entry['robots'] ?? 'noindex, nofollow',
'label' => $entry['label'] ?? '',
];
return $config;
}
}
// Auto-include dev.{primary_domain} if no aliases configured
if (empty($aliases))
{
$primary = $this->getPrimaryHost();
$devDomain = !empty($primary) ? 'dev.' . $primary : '';
if (!empty($devDomain) && strcasecmp($currentHost, $devDomain) === 0)
{
$config = [
'domain' => $devDomain,
'bypass_offline' => true,
'robots' => 'noindex, nofollow',
'label' => 'Development',
];
return $config;
}
}
}
catch (\Throwable $e)
{
$config = null;
}
return $config;
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Heartbeat (called from onExtensionAfterSave) // Heartbeat (called from onExtensionAfterSave)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -2702,12 +2506,9 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
$config = Factory::getConfig(); $config = Factory::getConfig();
$timestamp = time(); $timestamp = time();
$devDomain = $this->getDevAliasDomain();
$payload = [ $payload = [
'token' => $healthToken, 'token' => $healthToken,
'domain' => $domain, 'domain' => $domain,
'dev_domain' => $devDomain ?: null,
'site_name' => $config->get('sitename', 'Joomla'), 'site_name' => $config->get('sitename', 'Joomla'),
'site_url' => $siteUrl, 'site_url' => $siteUrl,
'joomla_version' => (new Version())->getShortVersion(), 'joomla_version' => (new Version())->getShortVersion(),
@@ -2760,18 +2561,15 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
if ($response->code >= 200 && $response->code < 300) if ($response->code >= 200 && $response->code < 300)
{ {
$this->app->enqueueMessage('MokoSuiteHQ heartbeat: site registered successfully.', 'message'); $this->app->enqueueMessage('MokoSuiteClientHQ heartbeat: site registered', 'message');
} }
else else
{ {
$body = json_decode($response->body, true);
$msg = $body['error'] ?? $body['message'] ?? ('HTTP ' . $response->code);
Log::add( Log::add(
\sprintf('Heartbeat HTTP %d: %s', $response->code, $response->body), \sprintf('Heartbeat HTTP %d: %s', $response->code, $response->body),
Log::WARNING, Log::WARNING,
'mokosuiteclient' 'mokosuiteclient'
); );
$this->app->enqueueMessage('MokoSuiteHQ heartbeat failed: ' . $msg, 'warning');
} }
} }
catch (\Throwable $e) catch (\Throwable $e)
@@ -8,7 +8,7 @@
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: Joomla.Plugin * DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuiteClient * INGROUP: MokoSuiteClient
* VERSION: 02.47.08 * VERSION: 02.46.99
* 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
*/ */
@@ -1,367 +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
*/
namespace Moko\Plugin\System\MokoSuiteClient\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* MokoGitea License Validator core DRM enforcement for the MokoSuite platform.
*
* Validates the site's DLID against MokoGitea, caches the result,
* and provides entitlement checking for all suite modules.
*
* Default Gitea server: git.mokoconsulting.tech
*
* @since 02.45.00
*/
final class LicenseValidator
{
/** @var string Default MokoGitea server address */
private const DEFAULT_GITEA_URL = 'https://git.mokoconsulting.tech';
/** @var int Cache TTL in seconds (24 hours) */
private const CACHE_TTL = 86400;
/** @var int Grace period in days after expiry before deactivation */
private const DEFAULT_GRACE_DAYS = 7;
/** @var object|null Cached license data for current request */
private static ?object $cachedLicense = null;
/**
* Validate the site's DLID against MokoGitea.
* Returns cached result if still valid; calls API if expired.
*/
public static function validate(bool $forceRefresh = false): object
{
if (self::$cachedLicense && !$forceRefresh) {
return self::$cachedLicense;
}
$db = Factory::getContainer()->get(DatabaseInterface::class);
$dlid = self::getDlid();
if (!$dlid) {
return self::$cachedLicense = (object) [
'valid' => false,
'status' => 'no_dlid',
'message' => 'No license key configured',
'entitlements'=> [],
];
}
// Check DB cache first
if (!$forceRefresh) {
$cached = self::getCachedResult($db, $dlid);
if ($cached) {
return self::$cachedLicense = $cached;
}
}
// Call MokoGitea API
$result = self::callGiteaApi($dlid);
// Cache the result
self::cacheResult($db, $dlid, $result);
return self::$cachedLicense = $result;
}
/**
* Check if the current license includes entitlement for a specific extension.
*
* @param string $extension Extension element name (e.g., 'com_mokosuite_crm', 'com_mokosuiterestaurant')
* @return bool
*/
public static function isEntitled(string $extension): bool
{
$license = self::validate();
if (!$license->valid) return false;
// Map extension names to repo identifiers
$repoMap = [
'com_mokosuite' => 'MokoSuite',
'com_mokosuite_crm' => 'MokoSuiteCRM',
'com_mokosuite_erp' => 'MokoSuiteERP',
'com_mokosuitechild' => 'MokoSuiteChild',
'com_mokosuitecreate' => 'MokoSuiteCreate',
'com_mokosuitenpo' => 'MokoSuiteNPO',
'com_mokosuitefield' => 'MokoSuiteField',
'com_mokosuitepos' => 'MokoSuitePOS',
'com_mokoshop' => 'MokoSuiteShop',
'com_mokosuitehrm' => 'MokoSuiteHRM',
'com_mokosuitemrp' => 'MokoSuiteMRP',
'com_mokosuiterestaurant' => 'MokoSuiteRestaurant',
];
$repo = $repoMap[$extension] ?? $extension;
$entitlements = $license->entitlements ?? [];
// Base is always entitled if license is valid
if ($repo === 'MokoSuite') return true;
return in_array($repo, $entitlements, true);
}
/**
* Get the full license status for admin display.
*/
public static function getStatus(): object
{
$license = self::validate();
return (object) [
'valid' => $license->valid ?? false,
'status' => $license->status ?? 'unknown',
'tier' => $license->tier ?? 'none',
'entitlements' => $license->entitlements ?? [],
'expires_at' => $license->expires_at ?? null,
'seats' => $license->seats ?? 0,
'seats_used' => $license->seats_used ?? 0,
'days_remaining'=> self::getDaysRemaining($license),
'in_grace' => self::isInGracePeriod($license),
'gitea_url' => self::getGiteaUrl(),
'dlid_configured' => (bool) self::getDlid(),
];
}
/**
* Get available seat count.
*/
public static function getAvailableSeats(): int
{
$license = self::validate();
$total = (int) ($license->seats ?? 0);
$used = (int) ($license->seats_used ?? 0);
if ($total === 0) return PHP_INT_MAX; // Unlimited seats
return max(0, $total - $used);
}
/**
* Report a heartbeat to MokoGitea (active installation check).
* Called by task scheduler daily.
*/
public static function heartbeat(): object
{
$dlid = self::getDlid();
if (!$dlid) return (object) ['success' => false, 'error' => 'No DLID'];
$giteaUrl = self::getGiteaUrl();
$siteUrl = \Joomla\CMS\Uri\Uri::root();
$joomlaVersion = (new \Joomla\CMS\Version())->getShortVersion();
// Count installed suite modules
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('element')
->from('#__extensions')
->where($db->quoteName('element') . ' LIKE ' . $db->quote('com_mokosuite%'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
->where($db->quoteName('enabled') . ' = 1'));
$installedModules = $db->loadColumn() ?: [];
$response = self::httpPost($giteaUrl . '/api/v1/licenses/heartbeat', [
'dlid' => $dlid,
'site_url' => $siteUrl,
'joomla_version' => $joomlaVersion,
'installed_modules' => $installedModules,
'php_version' => PHP_VERSION,
]);
return $response;
}
// ── Private methods ─────────────────────────────────
/**
* Get the configured DLID from component params.
*/
private static function getDlid(): string
{
try {
$params = Factory::getApplication()->getParams('com_mokosuite');
return trim($params->get('dlid', ''));
} catch (\Throwable $e) {
// Component not installed or params not available
try {
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('params')
->from('#__extensions')
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuite'))
->where($db->quoteName('type') . ' = ' . $db->quote('component')));
$paramsJson = $db->loadResult();
$params = json_decode($paramsJson ?: '{}', false);
return trim($params->dlid ?? '');
} catch (\Throwable $e2) {
return '';
}
}
}
/**
* Get the MokoGitea server URL from config.
*/
private static function getGiteaUrl(): string
{
try {
$params = Factory::getApplication()->getParams('com_mokosuite');
return rtrim($params->get('gitea_url', self::DEFAULT_GITEA_URL), '/');
} catch (\Throwable $e) {
return self::DEFAULT_GITEA_URL;
}
}
/**
* Call MokoGitea license validation API.
*/
private static function callGiteaApi(string $dlid): object
{
$giteaUrl = self::getGiteaUrl();
$response = self::httpGet($giteaUrl . '/api/v1/licenses/validate?dlid=' . urlencode($dlid));
if (isset($response->valid)) {
return (object) [
'valid' => (bool) $response->valid,
'status' => $response->status ?? 'unknown',
'tier' => $response->tier ?? '',
'entitlements' => $response->entitlements ?? $response->repo_scope ?? [],
'expires_at' => $response->expires_at ?? null,
'seats' => (int) ($response->seats ?? 0),
'seats_used' => (int) ($response->seats_used ?? 0),
'message' => $response->message ?? '',
];
}
// API error — use cached result if available, otherwise fail gracefully
return (object) [
'valid' => false,
'status' => 'api_error',
'message' => $response->error ?? 'Could not reach license server',
'entitlements' => [],
];
}
/**
* Get cached validation result from database.
*/
private static function getCachedResult(DatabaseInterface $db, string $dlid): ?object
{
$dlidHash = hash('sha256', $dlid);
try {
$db->setQuery($db->getQuery(true)
->select('response_data, checked_at')
->from('#__mokosuite_license_cache')
->where($db->quoteName('dlid_hash') . ' = ' . $db->quote($dlidHash))
->where('checked_at > DATE_SUB(NOW(), INTERVAL ' . (int) self::CACHE_TTL . ' SECOND)'));
$cached = $db->loadObject();
if ($cached && $cached->response_data) {
$data = json_decode($cached->response_data, false);
if ($data) return $data;
}
} catch (\Throwable $e) {
// Table may not exist yet — that's fine
}
return null;
}
/**
* Cache validation result in database.
*/
private static function cacheResult(DatabaseInterface $db, string $dlid, object $result): void
{
$dlidHash = hash('sha256', $dlid);
try {
// Upsert
$db->setQuery('REPLACE INTO #__mokosuite_license_cache (dlid_hash, response_data, checked_at) VALUES ('
. $db->quote($dlidHash) . ', '
. $db->quote(json_encode($result)) . ', '
. $db->quote(Factory::getDate()->toSql()) . ')');
$db->execute();
} catch (\Throwable $e) {
// Cache table may not exist — non-fatal
}
}
/**
* Calculate days remaining on license.
*/
private static function getDaysRemaining(object $license): ?int
{
if (empty($license->expires_at)) return null;
$now = new \DateTime('today');
$expiry = new \DateTime($license->expires_at);
$diff = (int) $now->diff($expiry)->format('%r%a');
return $diff;
}
/**
* Check if license is in grace period (expired but within grace window).
*/
private static function isInGracePeriod(object $license): bool
{
$days = self::getDaysRemaining($license);
if ($days === null || $days >= 0) return false;
$graceDays = self::DEFAULT_GRACE_DAYS;
try {
$graceDays = (int) Factory::getApplication()->getParams('com_mokosuite')->get('license_grace_days', self::DEFAULT_GRACE_DAYS);
} catch (\Throwable $e) {}
return abs($days) <= $graceDays;
}
/**
* HTTP GET helper.
*/
private static function httpGet(string $url): object
{
$response = file_get_contents($url, false, stream_context_create([
'http' => [
'method' => 'GET',
'header' => 'Accept: application/json',
'ignore_errors' => true,
'timeout' => 10,
],
]));
return json_decode($response ?: '{}', false) ?: (object) ['error' => 'No response'];
}
/**
* HTTP POST helper.
*/
private static function httpPost(string $url, array $data): object
{
$response = file_get_contents($url, false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nAccept: application/json",
'ignore_errors' => true,
'timeout' => 10,
'content' => json_encode($data),
],
]));
return json_decode($response ?: '{}', false) ?: (object) ['error' => 'No response'];
}
}
@@ -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.47.08</version> <version>02.46.99</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>
@@ -100,7 +100,7 @@
filter="url" /> filter="url" />
<field name="monitor_signing_key" type="hidden" <field name="monitor_signing_key" type="hidden"
default="LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktZd2dnU2lBZ0VBQW9JQkFRQ2xZNnNzOTZpeTZOOGMKTHRxbndhbnU4eEozdDcrdDhXT3hoY0Yyclc2QmlmOVhNaEpnYkw0c055N0wwV1dTT2tkMmZxalBNcDFtOFNyNAo1VnNycjE3cFc5b0FNMmtmdFdsaTZ1NkhTVEYyN2pVVUJrT3o4MHZMRklMMGNGNkJCUkpYN2JVWkRpamdUMjc1ClREb3dXZy82Zk9GeWFEelBHUkJuYXFacTljU2lEYWoyNlpSTVZIbktQUERWTG92VzRPTDQzL2gwZ3BtN25nUGIKdWJlLzFFTDRUMHFRbm1Xc2FEOFZ6VStoRXFGSDRTVUtMaDVNeklGbUxFZzRlZ0xCbTBXcWdxbzZRQVBnZDVPYgoybXhmQndta3RLVm5hcWR6eG9KSytzaTVuZkYreGpxbWRMZThUdmEyTHNuTUxlZmsrODVoQ3hxS2x1eWRta1lXCjlvUk5qcDhiQWdNQkFBRUNnZ0VBQkZOUS9NSVZaV2gxdlZUMFh3TFBvUEkyZjI4TTBrM0gzN0t4MXBxK2t5QzYKenRyK1pBczBCaEFEWjAwNHJOUmRYaG45N0QxVXBJYVdLeUJFZkNZQUEzWmxneS9WQmdGR21sR3VuMWNvdGdXUQoyYzg0SWhLdzNzVFFqL2dJWUxOelFWMTBLUTJYd0JZVHZ1MWhjRFpLeUxCUGJTQ1F4cEhQUGdVcUNRNFljR3lFClErVmc1dHJUYk8wQ2xCZ1U5bkVnYU1RakRJZ0F3WVZPV203dUxJTW84UC9nT3FuT2tmaFhzdzl3VTJVYWxFeTEKRmRZbGhMbGJ0ZS9MZ3lkYlJ2RStjNEtqZVp0Z3ptc1RneEh2dzM5YVVmZUZTclFRT0FjcXc0alNzUjdMck9UZAp5bDhpelRrZVBrTVFMamFqR0pabWdPbitkRzhtUlpMa3FKcWdGaVpqRVFLQmdRRFV0L0xlU0h5SmhvY3VFL240CkZreEpaclJoWUVsWnc2WlZJUnQzWDlPQ1Nmaklab3I1ZkZlczhvUzZySFhKdGZYeWx4QUxOSjJjTUhKTTViVnUKbUFSUFU4cThBeVc0OE03cHAyNmtVVTMxNXc2OU1SUkhzbWgyekRabEtDeG5GM1NSQ3U4YW95d3hZc3RUZ3hkTgo2bDhLNHZsS1dsN3FYblBhWjZjb3lQSU9od0tCZ1FESENuRmRRdW5SMVI2dkxGaVFZMTRiT3QwT0tzVGJYMUJyCmpvUGZySkxvRm5mSCs4VDVnNUdxYkV5T2p0WG1tRXhmTFFpcDBQVXRtc1E0YXlJRFBZYWZtU3RpK2dtQXZFd1MKZTlKcVYxYlRuazUrYnVRZ2FlOW16REpJWkxaczRJUlhrd1Q5aDZ4Q2xKeS80TGJSRHdBU3dUVGJlY01hN3A4UgpQN0p0bjdsYnpRS0JnQzNOR2FjUTFuZktGb3N1VS9FOTQ5a2VHeEtvWjhMREpLcEp3WjgzYTlRdTF6bFhFdTlhCi9ZbklnaG1yam9VSy85VG0vOVpaMHVIUmNKcnNEdCtzTGFsaThsRC9JSDBzcEhDYzAyN2Y3cmhXc3M2N3BaRTIKY2RXNmJLL2xNWUpWQTQxRFhHNVEyZkFjUklsTHZaWFNNL3FsR21ZUEJVYlRaWUNPTnVqS000dzdBb0dBU1dBdwpLcEZnWVZxUDFVUWo0aGEvdW9vWXRBQlFVZzd4TnJWektDSVdoampDTDVkQkpqcTZtSGtVUC9tb0lUcEQ3VkpNCnYwMnBGUWJaRDNOdk5vS1gvbjRZNElRTXZNaXR3cUtqRDFEalVXQXF6N0ZScUNGbGdDQUc2V2szVnl2dG5kczEKRzhISVgwTXFCaEp4VXVDVXhsVXpoelY4RjVHZ1VsdUpDNkMyVklFQ2dZQkJWSkxpZlNVOTlHWGZtK3dPd0RWcgo2bHZoUFgxOTBGVktWQXY3aVVWTXBwWXg4Y0QxYkcyUjRLT29JbnkxYTlxdjA2ZGFzeGVQOStkVjJVMWU3MWl5CkFXWDRBVHIrYitvSGk2eUk1MXRHRk54RUxiNXZYMVpYM3VNaDlWM29iYUpuSFNjYllpKzBBNjlyRmNuNEZuLzUKWXJybWxLTzRlRHFVZkswbVFJVCtwUT09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K" default="LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tDQpNSUlFdmdJQkFEQU5CZ2txaGtpRzl3MEJBUUVGQUFTQ0JLZ3dnZ1NrQWdFQUFvSUJBUUMvcnVrWE0zZHB0aDg2DQpGSkRXTjM0ZjQ2cUtJem1SMmFtTWUyZ2dWbWxsWnFyMHJkRFk4OTdtQ05FRkk4Q0NwNGR5amkwOU5ETnAvalFxDQovL2JGdUFNOUFTZU5oQTlmRlpwSG5UMGkzY3N4V3RSS2NnMnRkR0wzUXhNRFVBeFJYQ1RSQXVPSWZybGp6Ky85DQpWZ0ZtWHU3M1VSaU9XY1lLeFErejFoZkRGK2ZxRTRlYW9QcUlsY2J5dmtKd2lkSkRWUEEwc2RtbVlUTFg2Q29xDQpQalVDRENlbkZoUXNteVMzM29KSXArK0c3ZzU5NmRYelZIczRQSjIwNnc0Z3JlckRRZk5GVytzZndHSnl3NjBrDQpUQTVmUzF2Wit4NEt2UUh6V1ErYS9xRS9sSGxFVzdOTWVJWExNWGczSDd1eXBabXlVU2t3S0k0djFQQXRGWmtkDQpBaVpPZWZpVkFnTUJBQUVDZ2dFQVI0VGJyVDR0NWJ5MDhIQW0wcTR3WVF4REhEbVlJbzNXdDZ5MURmYU11OVMzDQpDYW5TMm9oazJzaE9TcGhhU2hFajI3WjBKY2hYdjhYWURvbU1BZmVsN3I5eDZjQ2FhTVdUNEdCMU5Zckp1NDhBDQprV2NteTkwWitPNTZQZkZJeTJXdXV6dFRxaFdZb0ZDSTBOZlU2bGw5SzhpSFl6VWx1MzZSSklweWx5OXFPKyt4DQpmTUZYcUovSkk0bVp6NW0raDBnbFMvN21VZ0EvUTRjbVJnRHJ3dkc3bEpBRjhWSDBEdW1uRWJkWkZvSi9XbU9JDQpSTi9lemhqczYrbU9hTnUwQWRsclpLU3QwRWZVYjl3QTFLQm5JMVVDU2w0Y1lidXVpL29jOWo1aGl6RGJvRWRyDQpJL1U5Y2FYUmZvb0pMNlUwOXN1VTdyTlFLbFRhMXM4NVhvY3htT0JMK1FLQmdRRHg5QzB5MjQ5SG1paXJ2WExIDQpBUXdUTjRyMjdhUTZMMFc2SHdDNHdzMUhleDRpeWRXT1lIcWdBSnY4VHZyeVpHOW1SaFh1U1ROTjYxV1UvTWFNDQphQVYwVjJ4Y0RrdDNFUnhNak1XRmhXUTh0cjN2RUtqWjFnOVJXOGhiTE9VYXVCcmJhMlI4RWNZYXFLZXlxR3N4DQpCa0VLZlRIUzNmUysraXNLZ2EzUU1mcjB6d0tCZ1FES3o2SGVKZ0tKRTVMM1ppbkhxaUFyVm5SZ2pYcFZrMWpvDQp6VXh5eTkwNEhmNGlmVXNIZklpdzVpN0VNR0U0RE5ob2MvZUJxcW1oM1N2ejJMUDNzOHUrL0hVZFllVzJIV1hhDQpKZlpMRE5BM0U3WDNkSVJ6MFg5UTh2OHcxaFpQeUxYOUlYeUVyUTNGZHFVdyt1Tko1VFZJell0RHppNnRKTjkvDQpGZGlxS0Q2ZFd3S0JnUURnQnE5bS9LWmdyTnRsa1FkYVBaejVtaDhBWGE4RzlNaEIrZnpJRmc3T1ZhL2tsQzg1DQpJaG5JVm1nWHFPVndWQkJWaVNVN09lbllCc042TE1hR01MYUVMNEkwaGtQWG5pOHVyZFVodVEzRHJZeVZjejUwDQpYR0JZZTN3Njk0bTJRS3NWYVExa1YyeXZPR1AxNXoxQTZrS0V2TURLTnhzclRTVlhHQlZneFRaUlB3S0JnUURBDQp1RFVVcUFIWXlDVHJ1c1VRMm5UZk9iUTAyN3ZYL2NDSzJDdEJHc0FJUjFmcTVpeVozSmozb0lQb0lpRC81aFR1DQpqT1F3N3o5cWRJVURublRGZUxDdnQ2NkNVVGk3cVl2VGxDZEtnYzZKeDgwdWJDWkErRjZIU2FGOWdyS0k5aTBaDQpjT3ltRnR2elBCOFZRQk1qY1E0Rk0yeVc3aUlrbmRsVEppdFE1aFU1NlFLQmdEZ1JIOXBEcGZwWlZ2V2g2MldGDQp5OGZzWUo1ODhzQmRMUlpTYTRuNi9XbjdUcUp1bWg2aWpFcDVyZFdnQkVtaDlJSk9jRUlhZ05mK0s5MXdoaThvDQpTeW01ajJpL1pjVVFYNFJSTDNxQ1RZZWVQVnZ3RHc3aWNLWVowTGQ2S1pFMmdEaDRPbEg4ejU0Zkl3a2tMSzRFDQpCcmtJNWppa05QSkJFR25zTm9zU3pWN2QNCi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0NCg=="
filter="raw" /> filter="raw" />
</fieldset> </fieldset>
</fields> </fields>
@@ -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.47.08 * VERSION: 02.46.99
* 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.47.08 * VERSION: 02.46.99
* 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.47.08</version> <version>02.46.99</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>
@@ -118,11 +118,13 @@ class Backup extends CMSPlugin implements SubscriberInterface
} }
// Prefer MokoSuiteBackup's own helper (clean public API) // Prefer MokoSuiteBackup's own helper (clean public API)
$helperClass = 'Joomla\\Component\\MokoSuiteBackup\\Administrator\\Helper\\BackupStatusHelper'; $helperClass = 'Joomla\\Component\\MokoSuiteBackup\\Administrator\\Utility\\BackupStatusHelper';
if (class_exists($helperClass)) if (class_exists($helperClass))
{ {
return $helperClass::getStatusSummary(); $staleDays = (int) $this->params->get('stale_days', 7);
return $helperClass::getStatus($staleDays);
} }
// Fallback: direct table query for older MokoSuiteBackup versions // Fallback: direct table query for older MokoSuiteBackup versions
@@ -242,52 +244,20 @@ class Backup extends CMSPlugin implements SubscriberInterface
? round($latest->total_size / 1048576) ? round($latest->total_size / 1048576)
: null; : null;
// Total counts (all time)
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
);
$allTime = (int) $db->loadResult();
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('fail'))
);
$allFailed = (int) $db->loadResult();
// Recent failures
$cutoff7d = date('Y-m-d H:i:s', strtotime('-7 days'));
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('fail'))
->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff7d))
);
$recentFailed = (int) $db->loadResult();
return [ return [
'installed' => true, 'installed' => true,
'latest' => [ 'status' => $status,
'status' => $latest->status, 'last_backup' => $latest->backupstart,
'backup_type' => $latest->backup_type ?? 'full', 'last_status' => $latest->status,
'description' => $latest->description ?? '', 'last_size_mb' => $sizeMb,
'backup_start' => $latest->backupstart, 'days_since' => $daysSince,
'backup_end' => $latest->backupend ?? null, 'backup_type' => $latest->backup_type,
'total_size' => (int) ($latest->total_size ?? 0), 'origin' => $latest->origin,
'origin' => $latest->origin ?? 'backend', 'total_backups' => $totalBackups,
], 'recent_7d' => $recentBackups,
'totals' => [ 'fail_count_7d' => $failCount7d,
'all_time' => $allTime, 'files_exist' => (bool) $latest->filesexist,
'all_success' => $totalBackups, 'description' => $latest->description,
'all_failed' => $allFailed,
'recent_total' => $recentBackups + $recentFailed,
'recent_success' => $recentBackups,
'recent_failed' => $recentFailed,
],
]; ];
} }
} }
@@ -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.47.08</version> <version>02.46.99</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>
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<form>
<field name="domain" type="text"
label="Domain"
hint="dev.clientsite.com"
filter="raw"
required="true" />
<field name="offline_bypass" type="list" default="1"
label="Offline Mode">
<option value="1">Bypass (stay online)</option>
<option value="0">Respect (go offline)</option>
</field>
<field name="robots" type="list" default="noindex, nofollow"
label="Robots">
<option value="noindex, nofollow">noindex, nofollow</option>
<option value="noindex">noindex</option>
<option value="index, follow">index, follow (production)</option>
</field>
<field name="label" type="text"
label="Label"
hint="Development, Staging, QA..."
filter="string" />
</form>
@@ -15,8 +15,3 @@ PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DELETE_VERSIONS_LABEL="Delete All Versions"
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DELETE_VERSIONS_DESC="One-shot: delete all content version history on save. Automatically turns off after execution." PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DELETE_VERSIONS_DESC="One-shot: delete all content version history on save. Automatically turns off after execution."
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_DLKEYS_LABEL="Reset Download Keys" PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_DLKEYS_LABEL="Reset Download Keys"
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_DLKEYS_DESC="One-shot: clear all download keys (dlid) from update sites on save. Automatically turns off after execution." PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_DLKEYS_DESC="One-shot: clear all download keys (dlid) from update sites on save. Automatically turns off after execution."
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_ALIASES="Mirror Domains & Staging Environments"
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_ALIASES_DESC="Configure domain aliases that share this site's hosting folder. Each mirror can independently bypass offline mode and control search engine indexing."
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_ALIASES_LABEL="Domain Mirrors"
PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_ALIASES_DESC="Add CNAME domains for development, staging, or QA. Each mirror gets its own offline and robots settings while sharing the same database and files."
@@ -8,14 +8,13 @@
<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.47.08</version> <version>02.46.99</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>
<files> <files>
<folder>src</folder> <folder>src</folder>
<folder>services</folder> <folder>services</folder>
<folder>forms</folder>
<folder>language</folder> <folder>language</folder>
</files> </files>
@@ -62,20 +61,6 @@
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
</fieldset> </fieldset>
<fieldset name="site_aliases"
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_ALIASES"
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_ALIASES_DESC">
<field name="site_aliases" type="subform"
label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_ALIASES_LABEL"
description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_ALIASES_DESC"
formsource="plugins/system/mokosuiteclient_devtools/forms/site_alias_entry.xml"
multiple="true"
layout="joomla.form.field.subform.repeatable-table"
groupByFieldset="false"
buttons="add,remove,move" />
</fieldset>
</fields> </fields>
</config> </config>
</extension> </extension>
@@ -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.47.08</version> <version>02.46.99</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>
@@ -33,17 +33,12 @@
</languages> </languages>
<config> <config>
<fields name="params" <fields name="params">
addfieldprefix="Moko\Plugin\System\MokoSuiteClientFirewall\Field">
<!-- Network & Session --> <!-- Network & Session -->
<fieldset name="basic" <fieldset name="basic"
label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_BASIC" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_BASIC"
description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_BASIC_DESC"> description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_BASIC_DESC">
<field name="current_ip_display" type="CurrentIp"
label="Your Current IP Address"
description="This is the IP address you are connecting from. Copy it to add to the Trusted IPs list below." />
<field name="force_https" type="radio" default="1" <field name="force_https" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FORCE_HTTPS_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FORCE_HTTPS_LABEL"
description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FORCE_HTTPS_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FORCE_HTTPS_DESC"
@@ -1,42 +0,0 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage plg_system_mokosuiteclient_firewall
*/
namespace Moko\Plugin\System\MokoSuiteClientFirewall\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\FormField;
/**
* Read-only field that displays the current user's IP address.
* Useful for quickly copying the IP to add to trusted IPs list.
*/
class CurrentIpField extends FormField
{
protected $type = 'CurrentIp';
protected function getInput(): string
{
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
if (!empty($ip))
{
$ip = trim(explode(',', $ip)[0]);
}
if (empty($ip))
{
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
return '<div class="d-flex align-items-center gap-2">'
. '<code style="font-size:1.1rem;padding:0.4rem 0.8rem;background:#f8f9fa;border:1px solid #dee2e6;border-radius:4px;" id="mokosuiteclient-current-ip">'
. htmlspecialchars($ip)
. '</code>'
. '<button type="button" class="btn btn-sm btn-outline-secondary" onclick="navigator.clipboard.writeText(document.getElementById(\'mokosuiteclient-current-ip\').textContent.trim()).then(function(){this.textContent=\'Copied!\';var b=this;setTimeout(function(){b.textContent=\'Copy\'},1500)}.bind(this))" title="Copy IP to clipboard">Copy</button>'
. '</div>';
}
}
@@ -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.47.08</version> <version>02.46.99</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>
@@ -0,0 +1,13 @@
; MokoSuiteClient Health Monitor Plugin
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
PLG_SYSTEM_MOKOSUITECLIENT_MONITOR="System - MokoSuiteClient Monitor"
PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_DESC="Sends heartbeat data to a MokoSuiteClientHQ control panel for centralized site monitoring."
PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_FIELDSET_BASIC="Monitoring"
PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_FIELDSET_BASIC_DESC="Configure heartbeat reporting to MokoSuiteClientHQ."
PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_HEARTBEAT_LABEL="Send Heartbeat"
PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_HEARTBEAT_DESC="Send heartbeat data to MokoSuiteClientHQ when plugin settings are saved."
PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_LABEL="MokoSuiteClientHQ URL"
PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_DESC="URL of the MokoSuiteClientHQ control panel (e.g. https://mokoconsulting.tech). The heartbeat is sent to /api/index.php/v1/mokosuiteclienthq/heartbeat on this host."
@@ -0,0 +1,3 @@
; MokoSuiteClient Health Monitor Plugin - System strings
PLG_SYSTEM_MOKOSUITECLIENT_MONITOR="System - MokoSuiteClient Monitor"
PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_DESC="Site health monitoring, MokoSuiteClientHQ heartbeat integration, and diagnostics."
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteClient Monitor</name>
<element>mokosuiteclient_monitor</element>
<author>Moko Consulting</author>
<creationDate>2026-06-02</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.46.99</version>
<description>PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteClientMonitor</namespace>
<files>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_system_mokosuiteclient_monitor.ini</language>
<language tag="en-GB">en-GB/plg_system_mokosuiteclient_monitor.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic"
label="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_FIELDSET_BASIC"
description="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_FIELDSET_BASIC_DESC">
<field name="heartbeat_enabled" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_HEARTBEAT_LABEL"
description="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_HEARTBEAT_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="base_url" type="url"
default="https://waas.dev.mokoconsulting.tech"
label="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_LABEL"
description="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_DESC"
filter="url" />
<field name="signing_key" type="hidden"
default="LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tDQpNSUlFdmdJQkFEQU5CZ2txaGtpRzl3MEJBUUVGQUFTQ0JLZ3dnZ1NrQWdFQUFvSUJBUUMvcnVrWE0zZHB0aDg2DQpGSkRXTjM0ZjQ2cUtJem1SMmFtTWUyZ2dWbWxsWnFyMHJkRFk4OTdtQ05FRkk4Q0NwNGR5amkwOU5ETnAvalFxDQovL2JGdUFNOUFTZU5oQTlmRlpwSG5UMGkzY3N4V3RSS2NnMnRkR0wzUXhNRFVBeFJYQ1RSQXVPSWZybGp6Ky85DQpWZ0ZtWHU3M1VSaU9XY1lLeFErejFoZkRGK2ZxRTRlYW9QcUlsY2J5dmtKd2lkSkRWUEEwc2RtbVlUTFg2Q29xDQpQalVDRENlbkZoUXNteVMzM29KSXArK0c3ZzU5NmRYelZIczRQSjIwNnc0Z3JlckRRZk5GVytzZndHSnl3NjBrDQpUQTVmUzF2Wit4NEt2UUh6V1ErYS9xRS9sSGxFVzdOTWVJWExNWGczSDd1eXBabXlVU2t3S0k0djFQQXRGWmtkDQpBaVpPZWZpVkFnTUJBQUVDZ2dFQVI4VGJyVDR0NWJ5MDhIQW0wcTR3WVF4REhEbVlJbzNXdDZ5MURmYU11OVMzDQpDYW5TMm9oazJzaE9TcGhhU2hFajI3WjBKY2hYdjhYWURvbU1BZmVsN3I5eDZjQ2FhTVdUNEdCMU5Zckp1NDhBDQprV2NteTkwWitPNTZQZkZJeTJXdXV6dFRxaFdZb0ZDSTBOZlU2bGw5SzhpSFl6VWx1MzZSSklweWx5OXFPKyt4DQpmTUZYcUovSkk0bVp6NW0raDBnbFMvN21VZ0EvUTRjbVJnRHJ3dkc3bEpBRjhWSDBEdW1uRWJkWkZvSi9XbU9JDQpSTi9lemhqczYrbU9hTnUwQWRsclpLU3QwRWZVYjl3QTFLQm5JMVVDU2w0Y1lidXVpL29jOWo1aGl6RGJvRWRyDQpJL1U5Y2FYUmZvb0pMNlUwOXN1VTdyTlFLbFRhMXM4NVhvY3htT0JMK1FLQmdRRHg5QzB5MjQ5SG1paXJ2WExIDQpBUXdUTjRyMjdhUTZMMFc2SHdDNHdzMUhleDRpeWRXT1lIcWdBSnY4VHZyeVpHOW1SaFh1U1ROTjYxV1UvTWFNDQphQVYwVjJ4Y0RrdDNFUnhNak1XRmhXUTh0cjN2RUtqWjFnOVJXOGhiTE9VYXVCcmJhMlI4RWNZYXFLZXlxR3N4DQpCa0VLZlRIUzNmUysraXNLZ2EzUU1mcjB6d0tCZ1FES3o2SGVKZ0tKRTVMM1ppbkhxaUFyVm5SZ2pYcFZrMWpvDQp6VXh5eTkwNEhmNGlmVXNIZklpdzVpN0VNR0U0RE5ob2MvZUJxcW1oM1N2ejJMUDNzOHUrL0hVZFllVzJIV1hhDQpKZlpMRE5BM0U3WDNkSVJ6MFg5UTh2OHcxaFpQeUxYOUlYeUVyUTNGZHFVdyt1Tko1VFZJell0RHppNnRKTjkvDQpGZGlxS0Q2ZFd3S0JnUURnQnE5bS9LWmdyTnRsa1FkYVBaejVtaDhBWGE4RzlNaEIrZnpJRmc3T1ZhL2tsQzg1DQpJaG5JVm1nWHFPVndWQkJWaVNVN09lbllCc042TE1hR01MYUVMNEkwaGtQWG5pOHVyZFVodVEzRHJZeVZjejUwDQpYR0JZZTN3Njk0bTJRS3NWYVExa1YyeXZPR1AxNXoxQTZrS0V2TURLTnhzclRTVlhHQlZneFRaUlB3S0JnUURBDQp1RFVVcUFIWXlDVHJ1c1VRMm5UZk9iUTAyN3ZYL2NDSzJDdEJHc0FJUjFmcTVpeVozSmozb0lQb0lpRC81aFR1DQpqT1F3N3o5cWRJVURublRGZUxDdnQ2NkNVVGk3cVl2VGxDZEtnYzZKeDgwdWJDWkErRjZIU2FGOWdyS0k5aTBaDQpjT3ltRnR2elBCOFZRQk1qY1E4Rk0yeVc3aUlrbmRsVEppdFE1aFU1NlFLQmdEZ1JIOXBEcGZwWlZ2V2g2MldGDQp5OGZzWUo1ODhzQmRMUlpTYTRuNi9XbjdUcUp1bWg2aWpFcDVyZFdnQkVtaDlJSk9jRUlhZ05mK0s5MXdoaThvDQpTeW01ajJpL1pjVVFYNFJSTDNxQ1RZZWVQVnZ3RHc3aWNLWVowTGQ2S1pFMmdEaDRPbEg4ejU0Zkl3a2tMSzRFDQpCcmtJNWppa05QSkJFR25zTm9zU3pWN2QNCi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0NCg=="
filter="raw" />
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,34 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage plg_system_mokosuiteclient_monitor
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Moko\Plugin\System\MokoSuiteClientMonitor\Extension\Monitor;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new Monitor($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuiteclient_monitor'));
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,353 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage plg_system_mokosuiteclient_monitor
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\System\MokoSuiteClientMonitor\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\Version;
use Joomla\Event\SubscriberInterface;
use Moko\Plugin\System\MokoSuiteClient\Helper\MokoSuiteClientHelper;
/**
* MokoSuiteClient Health Monitor Plugin
*
* Sends heartbeat data to a MokoSuiteClientHQ control panel instance.
* Each request is RSA-signed with a private key distributed via
* the package manifest, verified by Base using the matching public key.
*
* @since 02.32.00
*/
class Monitor extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onExtensionAfterSave' => 'onExtensionAfterSave',
'onAfterInitialise' => 'onAfterInitialise',
'onExtensionAfterInstall' => 'onExtensionAfterInstall',
];
}
/**
* Send heartbeat on first admin page load after install/update.
*/
public function onAfterInitialise(): void
{
$app = $this->getApplication();
if (!$app->isClient('administrator')) return;
if (!$this->params->get('heartbeat_enabled', 1)) return;
$session = \Joomla\CMS\Factory::getSession();
if ($session->get('mokosuiteclient.heartbeat_sent', false)) return;
// Check if version changed since last heartbeat
$lastVersion = $this->params->get('_last_heartbeat_version', '');
$currentVersion = $this->getMokoSuiteClientVersion();
if ($lastVersion !== $currentVersion)
{
$session->set('mokosuiteclient.heartbeat_sent', true);
$this->sendHeartbeat();
// Store version so we don't re-send every session
try
{
$this->params->set('_last_heartbeat_version', $currentVersion);
$extension = new \Joomla\CMS\Table\Extension(Factory::getDbo());
$extension->load(['element' => 'mokosuiteclient_monitor', 'folder' => 'system', 'type' => 'plugin']);
$extension->params = $this->params->toString();
$extension->store();
}
catch (\Throwable $e) {}
}
}
/**
* Send heartbeat immediately after package install/update.
*/
public function onExtensionAfterInstall($installer, $eid): void
{
if (!$this->params->get('heartbeat_enabled', 1)) return;
try { $this->sendHeartbeat(); }
catch (\Throwable $e) {}
}
/**
* After saving this plugin or the core plugin, send heartbeat.
*/
public function onExtensionAfterSave($event): void
{
// Joomla 6: single event object; Joomla 5: individual args
if (is_object($event) && method_exists($event, 'getArgument'))
{
$context = $event->getArgument('context', $event->getArgument(0, ''));
$table = $event->getArgument('subject', $event->getArgument(1, null));
}
else
{
$context = $event;
$table = func_get_arg(1);
}
if ($context !== 'com_plugins.plugin' || !$table)
{
return;
}
$element = $table->element ?? '';
if (!\in_array($element, ['mokosuiteclient', 'mokosuiteclient_monitor'], true))
{
return;
}
if (!$this->params->get('heartbeat_enabled', 1))
{
return;
}
$this->sendHeartbeat();
}
/**
* Send heartbeat to the MokoSuiteClientHQ control panel.
*
* The request is RSA-signed: the client signs domain|timestamp|token
* with its private key. Base verifies with the matching public key.
*/
private function sendHeartbeat(): void
{
$baseUrl = rtrim($this->params->get('base_url', ''), '/');
if (empty($baseUrl))
{
return;
}
$coreParams = MokoSuiteClientHelper::getCoreParams();
$healthToken = $coreParams->get('health_api_token', '');
if (empty($healthToken))
{
return;
}
$siteUrl = rtrim(Uri::root(), '/');
$domain = parse_url($siteUrl, PHP_URL_HOST) ?: '';
if (empty($domain))
{
return;
}
$app = $this->getApplication();
$config = Factory::getConfig();
$timestamp = time();
$payload = [
'token' => $healthToken,
'domain' => $domain,
'site_name' => $config->get('sitename', 'Joomla'),
'site_url' => $siteUrl,
'joomla_version' => (new Version())->getShortVersion(),
'php_version' => PHP_VERSION,
'mokosuiteclient_version' => $this->getMokoSuiteClientVersion(),
'timestamp' => $timestamp,
'client_info' => [
'company' => $config->get('sitename', ''),
'email' => $config->get('mailfrom', ''),
],
];
// Include live health data
$healthData = $this->fetchLocalHealth($siteUrl, $healthToken);
if ($healthData !== null)
{
$payload['health'] = $healthData;
}
// RSA sign the request
$headers = ['Content-Type: application/json'];
$signature = $this->signRequest($domain, $timestamp, $healthToken);
if ($signature !== null)
{
$headers[] = 'X-MokoSuite-Signature: ' . $signature;
$headers[] = 'X-MokoSuite-Timestamp: ' . $timestamp;
}
$endpoint = $baseUrl . '/api/index.php/v1/mokosuitehq/heartbeat';
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
try
{
$http = \Joomla\CMS\Http\HttpFactory::getHttp(
new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]),
['curl', 'stream']
);
$headerMap = [];
foreach ($headers as $h)
{
[$key, $val] = explode(': ', $h, 2);
$headerMap[$key] = $val;
}
$response = $http->post($endpoint, $json, $headerMap, 15);
$code = $response->code;
$body = json_decode($response->body, true);
if ($code >= 200 && $code < 300)
{
$app->enqueueMessage(
'MokoSuiteClientHQ heartbeat: ' . ($body['status'] ?? 'ok'),
'message'
);
}
else
{
Log::add(
\sprintf('Monitor heartbeat HTTP %d: %s', $code, $body['error'] ?? 'Unknown'),
Log::WARNING,
'mokosuiteclient'
);
$app->enqueueMessage(
'MokoSuiteClientHQ heartbeat failed (HTTP ' . $code . ')',
'warning'
);
}
}
catch (\Throwable $e)
{
Log::add('Monitor heartbeat failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* RSA-sign the request message.
*
* @param string $domain Site domain.
* @param int $timestamp Unix timestamp.
* @param string $token Health API token.
*
* @return string|null Base64-encoded signature, or null if signing fails.
*/
private function signRequest(string $domain, int $timestamp, string $token): ?string
{
$signingKeyB64 = $this->params->get('signing_key', '');
// Fall back to manifest XML default if not yet saved in params
if (empty($signingKeyB64))
{
$manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient_monitor/mokosuiteclient_monitor.xml';
if (is_file($manifestFile))
{
$xml = simplexml_load_file($manifestFile);
if ($xml)
{
foreach ($xml->xpath('//field[@name="signing_key"]') as $field)
{
$signingKeyB64 = (string) $field['default'];
break;
}
}
}
}
if (empty($signingKeyB64))
{
return null;
}
$privateKeyPem = base64_decode($signingKeyB64);
if (empty($privateKeyPem))
{
return null;
}
$message = $domain . '|' . $timestamp . '|' . $token;
$privateKey = openssl_pkey_get_private($privateKeyPem);
if ($privateKey === false)
{
return null;
}
$signature = '';
if (openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256))
{
return base64_encode($signature);
}
return null;
}
/**
* Fetch health data from the local site's health endpoint.
*/
private function fetchLocalHealth(string $siteUrl, string $healthToken): ?array
{
try
{
$http = \Joomla\CMS\Http\HttpFactory::getHttp(
new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]),
['curl', 'stream']
);
$response = $http->get(
$siteUrl . '/?mokosuiteclient=health',
['Authorization' => 'Bearer ' . $healthToken, 'Accept' => 'application/json'],
10
);
if ($response->code !== 200 || empty($response->body))
{
return null;
}
return json_decode($response->body, true) ?: null;
}
catch (\Throwable $e)
{
return null;
}
}
/**
* Get the installed MokoSuiteClient package version.
*/
private function getMokoSuiteClientVersion(): string
{
try
{
$extension = new \Joomla\CMS\Table\Extension(Factory::getDbo());
$extension->load(['element' => 'pkg_mokosuiteclient', 'type' => 'package']);
$manifest = json_decode($extension->manifest_cache ?? '{}');
return $manifest->version ?? '';
}
catch (\Throwable $e)
{
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.47.08</version> <version>02.46.99</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.47.08</version> <version>02.46.99</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>
@@ -0,0 +1,8 @@
PLG_TASK_MOKOSUITECLIENT_TICKETS="Task - MokoSuiteClient Ticket Automation"
PLG_TASK_MOKOSUITECLIENT_TICKETS_DESC="Runs scheduled helpdesk automation rules."
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION_TITLE="MokoSuiteClient: Ticket Automation"
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION_DESC="Runs time-based automation rules against open tickets (auto-close, SLA escalation, etc.)."
PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL_TITLE="MokoSuiteClient: IMAP Email Polling"
PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL_DESC="Polls an IMAP inbox for new emails and creates tickets or replies from unread messages."
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE_TITLE="MokoSuiteClient: Auto-Close Resolved Tickets"
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE_DESC="Automatically closes tickets that have been in resolved status longer than the configured number of days."
@@ -0,0 +1,2 @@
PLG_TASK_MOKOSUITECLIENT_TICKETS="Task - MokoSuiteClient Ticket Automation"
PLG_TASK_MOKOSUITECLIENT_TICKETS_DESC="Runs scheduled helpdesk automation rules — auto-close, SLA escalation, and time-based actions."
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteClient Ticket Automation</name>
<element>mokosuiteclient_tickets</element>
<author>Moko Consulting</author>
<creationDate>2026-06-02</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.46.99</version>
<description>Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions.</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientTickets</namespace>
<files>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_task_mokosuiteclient_tickets.ini</language>
<language tag="en-GB">en-GB/plg_task_mokosuiteclient_tickets.sys.ini</language>
</languages>
</extension>
@@ -0,0 +1,27 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Moko\Plugin\Task\MokoSuiteClientTickets\Extension\TicketAutomation;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new TicketAutomation($dispatcher, (array) PluginHelper::getPlugin('task', 'mokosuiteclient_tickets'));
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,313 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage plg_task_mokosuiteclient_tickets
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\Task\MokoSuiteClientTickets\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
use Joomla\Component\Scheduler\Administrator\Task\Status;
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
use Joomla\Event\SubscriberInterface;
use Moko\Component\MokoSuiteClient\Administrator\Model\TicketsModel;
use Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService;
use Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService;
class TicketAutomation extends CMSPlugin implements SubscriberInterface
{
use TaskPluginTrait;
protected const TASKS_MAP = [
'mokosuiteclient.ticket.automation' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION',
'method' => 'runAutomation',
],
'mokosuiteclient.ticket.imap_poll' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL',
'method' => 'runImapPoll',
],
'mokosuiteclient.ticket.autoclose' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE',
'method' => 'runAutoClose',
],
];
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onTaskOptionsList' => 'advertiseRoutines',
'onExecuteTask' => 'standardRoutineHandler',
'onContentPrepareForm' => 'enhanceTaskItemForm',
];
}
/**
* Run all scheduled automation rules against open tickets.
*/
private function runAutomation(ExecuteTaskEvent $event): int
{
try
{
$model = new TicketsModel();
$results = $model->runScheduledAutomation();
$this->logTask(
\sprintf('Ticket automation: evaluated %d tickets, acted on %d', $results['evaluated'], $results['acted'])
);
return Status::OK;
}
catch (\Throwable $e)
{
$this->logTask('Ticket automation failed: ' . $e->getMessage(), 'error');
return Status::KNOCKOUT;
}
}
/**
* Poll IMAP inbox and create tickets from unread emails (#136).
*/
private function runImapPoll(ExecuteTaskEvent $event): int
{
$config = $this->getComponentConfig();
$host = $config['imap_host'] ?? '';
$port = (int) ($config['imap_port'] ?? 993);
$user = $config['imap_user'] ?? '';
$pass = $config['imap_password'] ?? '';
$ssl = ($config['imap_ssl'] ?? '1') === '1';
$folder = $config['imap_folder'] ?? 'INBOX';
$processed = $config['imap_processed_folder'] ?? 'INBOX.Processed';
$defaultCat = (int) ($config['default_category'] ?? 0) ?: null;
if (empty($host) || empty($user) || empty($pass))
{
$this->logTask('IMAP not configured — skipping', 'warning');
return Status::OK;
}
if (!function_exists('imap_open'))
{
$this->logTask('php-imap extension not available', 'error');
return Status::KNOCKOUT;
}
$mailbox = '{' . $host . ':' . $port . '/imap' . ($ssl ? '/ssl' : '') . '/novalidate-cert}' . $folder;
$mbox = @imap_open($mailbox, $user, $pass);
if (!$mbox)
{
$this->logTask('IMAP connection failed: ' . imap_last_error(), 'error');
return Status::KNOCKOUT;
}
$db = Factory::getDbo();
$created = 0;
$replied = 0;
$emails = imap_search($mbox, 'UNSEEN');
if ($emails === false)
{
imap_close($mbox);
$this->logTask('No new emails');
return Status::OK;
}
foreach ($emails as $msgNum)
{
try
{
$header = imap_headerinfo($mbox, $msgNum);
$subject = isset($header->subject) ? imap_utf8($header->subject) : '(no subject)';
$fromAddr = $header->from[0]->mailbox . '@' . $header->from[0]->host;
$body = $this->getImapBody($mbox, $msgNum);
// Match sender to Joomla user
$userId = $this->findUserByEmail($fromAddr);
// Check if this is a reply (subject contains [#123])
$ticketId = 0;
if (preg_match('/\[#(\d+)\]/', $subject, $m))
{
$ticketId = (int) $m[1];
}
if ($ticketId > 0)
{
// Add as reply to existing ticket
$reply = (object) [
'ticket_id' => $ticketId,
'user_id' => $userId,
'body' => $body,
'is_internal' => 0,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply, 'id');
$replied++;
// Notify
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_tickets')->where('id = ' . $ticketId));
$ticket = $db->loadObject();
if ($ticket) {
NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]);
}
}
else
{
// Create new ticket
$ticket = (object) [
'subject' => $subject,
'body' => $body,
'status' => 'open',
'priority' => 'normal',
'category_id' => $defaultCat,
'created_by' => $userId,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id');
$created++;
NotificationService::notify('ticket_created', $ticket);
}
// Mark as seen / move to processed folder
imap_setflag_full($mbox, (string) $msgNum, '\\Seen');
if ($processed && $processed !== $folder)
{
@imap_mail_move($mbox, (string) $msgNum, $processed);
}
}
catch (\Throwable $e)
{
Log::add('IMAP message processing error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
imap_expunge($mbox);
imap_close($mbox);
$this->logTask("IMAP poll: {$created} tickets created, {$replied} replies added");
return Status::OK;
}
/**
* Auto-close resolved tickets after configured days.
*/
private function runAutoClose(ExecuteTaskEvent $event): int
{
$config = $this->getComponentConfig();
$days = (int) ($config['autoclose_days'] ?? 7);
if ($days <= 0)
{
$this->logTask('Auto-close disabled (days = 0)');
return Status::OK;
}
$db = Factory::getDbo();
$cutoff = Factory::getDate('-' . $days . ' days')->toSql();
$db->setQuery(
"UPDATE {$db->quoteName('#__mokosuiteclient_tickets')}"
. " SET status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}"
. " WHERE status = 'resolved'"
. " AND resolved IS NOT NULL"
. " AND resolved < {$db->quote($cutoff)}"
);
$db->execute();
$closed = $db->getAffectedRows();
$this->logTask("Auto-close: {$closed} tickets closed (resolved > {$days} days ago)");
return Status::OK;
}
// ── Helpers ──────────────────────────────────────────────────
private function getComponentConfig(): array
{
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('params')
->from('#__extensions')
->where('element = ' . $db->quote('com_mokosuiteclient'))
->where('type = ' . $db->quote('component'))
);
return json_decode($db->loadResult() ?? '{}', true) ?: [];
}
catch (\Throwable $e)
{
Log::add('Failed to load component config: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
return [];
}
}
private function findUserByEmail(string $email): int
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('id')
->from('#__users')
->where('email = ' . $db->quote($email))
->setLimit(1)
);
return (int) $db->loadResult();
}
private function getImapBody($mbox, int $msgNum): string
{
$structure = imap_fetchstructure($mbox, $msgNum);
// Simple single-part message
if (empty($structure->parts))
{
$body = imap_fetchbody($mbox, $msgNum, '1');
if ($structure->encoding === 3) $body = base64_decode($body);
if ($structure->encoding === 4) $body = quoted_printable_decode($body);
return trim(strip_tags($body));
}
// Multipart — find text/plain or text/html
$textBody = '';
foreach ($structure->parts as $i => $part)
{
$partNum = (string) ($i + 1);
if ($part->type === 0) // text
{
$content = imap_fetchbody($mbox, $msgNum, $partNum);
if ($part->encoding === 3) $content = base64_decode($content);
if ($part->encoding === 4) $content = quoted_printable_decode($content);
$subtype = strtolower($part->subtype ?? '');
if ($subtype === 'plain' && empty($textBody))
{
$textBody = $content;
}
elseif ($subtype === 'html' && empty($textBody))
{
$textBody = strip_tags($content);
}
}
}
return trim($textBody);
}
}
@@ -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.47.08</version> <version>02.46.99</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.47.08 * VERSION: 02.46.99
* BRIEF: Content-only snapshot/restore for demo site reset * BRIEF: Content-only snapshot/restore for demo site reset
*/ */
@@ -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.47.08</version> <version>02.46.99</version>
<description>PLG_TASK_MOKOSUITECLIENTSYNC_DESC</description> <description>PLG_TASK_MOKOSUITECLIENTSYNC_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientSync</namespace> <namespace path="src">Moko\Plugin\Task\MokoSuiteClientSync</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/ContentSyncReceiver.php * PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncReceiver.php
* VERSION: 02.47.08 * VERSION: 02.46.99
* BRIEF: Receiver-side content sync applies incoming payload to local DB * BRIEF: Receiver-side content sync applies incoming payload to local DB
*/ */
@@ -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/ContentSyncService.php * PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncService.php
* VERSION: 02.47.08 * VERSION: 02.46.99
* BRIEF: Sender-side content sync builds payload and pushes to remote sites * BRIEF: Sender-side content sync builds payload and pushes to remote sites
*/ */
@@ -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.47.08</version> <version>02.46.99</version>
<description>Joomla Web Services API routes for MokoSuiteClient site management — health checks, cache, updates, backups, and site info.</description> <description>Joomla Web Services API routes for MokoSuiteClient site management — health checks, cache, updates, backups, and site info.</description>
<namespace path="src">Moko\Plugin\WebServices\MokoSuiteClient</namespace> <namespace path="src">Moko\Plugin\WebServices\MokoSuiteClient</namespace>
<files> <files>
+3 -1
View File
@@ -2,7 +2,7 @@
<extension type="package" method="upgrade"> <extension type="package" method="upgrade">
<name>Package - MokoSuiteClient</name> <name>Package - MokoSuiteClient</name>
<packagename>mokosuiteclient</packagename> <packagename>mokosuiteclient</packagename>
<version>02.47.08</version> <version>02.46.99</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -25,9 +25,11 @@
<file type="module" id="mod_mokosuiteclient_menu" client="administrator">mod_mokosuiteclient_menu.zip</file> <file type="module" id="mod_mokosuiteclient_menu" client="administrator">mod_mokosuiteclient_menu.zip</file>
<file type="module" id="mod_mokosuiteclient_cache" client="administrator">mod_mokosuiteclient_cache.zip</file> <file type="module" id="mod_mokosuiteclient_cache" client="administrator">mod_mokosuiteclient_cache.zip</file>
<file type="module" id="mod_mokosuiteclient_categories" client="administrator">mod_mokosuiteclient_categories.zip</file> <file type="module" id="mod_mokosuiteclient_categories" client="administrator">mod_mokosuiteclient_categories.zip</file>
<file type="plugin" id="plg_system_mokosuiteclient_backup" group="system">plg_system_mokosuiteclient_backup.zip</file>
<file type="plugin" id="plg_webservices_mokosuiteclient" group="webservices">plg_webservices_mokosuiteclient.zip</file> <file type="plugin" id="plg_webservices_mokosuiteclient" group="webservices">plg_webservices_mokosuiteclient.zip</file>
<file type="plugin" id="plg_task_mokosuiteclientdemo" group="task">plg_task_mokosuiteclientdemo.zip</file> <file type="plugin" id="plg_task_mokosuiteclientdemo" group="task">plg_task_mokosuiteclientdemo.zip</file>
<file type="plugin" id="plg_task_mokosuiteclientsync" group="task">plg_task_mokosuiteclientsync.zip</file> <file type="plugin" id="plg_task_mokosuiteclientsync" group="task">plg_task_mokosuiteclientsync.zip</file>
<file type="plugin" id="plg_task_mokosuiteclient_tickets" group="task">plg_task_mokosuiteclient_tickets.zip</file>
</files> </files>
<updateservers> <updateservers>
+28 -284
View File
@@ -46,9 +46,6 @@ class Pkg_MokosuiteclientInstallerScript
{ {
$this->saveDownloadKey(); $this->saveDownloadKey();
// Joomla's package installer INSERTs extension rows without element first.
// MySQL strict mode requires a default. Set DEFAULT '' so the INSERT succeeds,
// then postflight cleans up the empty-element rows and stale files.
try try
{ {
$db = Factory::getDbo(); $db = Factory::getDbo();
@@ -58,16 +55,12 @@ class Pkg_MokosuiteclientInstallerScript
} }
catch (\Throwable $e) catch (\Throwable $e)
{ {
// Non-fatal // Non-fatal — column may already have a default
} }
} }
/** @var \Joomla\CMS\Installer\InstallerAdapter|null */
private $installerParent = null;
public function postflight($type, $parent) public function postflight($type, $parent)
{ {
$this->installerParent = $parent;
// Migrate MokoWaaS database tables to MokoSuiteClient naming // Migrate MokoWaaS database tables to MokoSuiteClient naming
$this->migrateWaasTables(); $this->migrateWaasTables();
@@ -89,9 +82,11 @@ class Pkg_MokosuiteclientInstallerScript
$this->enablePlugin('system', 'mokosuiteclient_devtools'); $this->enablePlugin('system', 'mokosuiteclient_devtools');
$this->enablePlugin('system', 'mokosuiteclient_offline'); $this->enablePlugin('system', 'mokosuiteclient_offline');
$this->enablePlugin('system', 'mokosuiteclient_dbip'); $this->enablePlugin('system', 'mokosuiteclient_dbip');
$this->enablePlugin('system', 'mokosuiteclient_backup');
$this->enablePlugin('webservices', 'mokosuiteclient'); $this->enablePlugin('webservices', 'mokosuiteclient');
$this->enablePlugin('task', 'mokosuiteclientdemo'); $this->enablePlugin('task', 'mokosuiteclientdemo');
$this->enablePlugin('task', 'mokosuiteclientsync'); $this->enablePlugin('task', 'mokosuiteclientsync');
$this->enablePlugin('task', 'mokosuiteclient_tickets');
// Migrate params from core plugin to feature plugins (one-time) // Migrate params from core plugin to feature plugins (one-time)
$this->migrateFeatureParams(); $this->migrateFeatureParams();
@@ -114,15 +109,21 @@ class Pkg_MokosuiteclientInstallerScript
// Set up MokoSuiteClient guided tours and unpublish Joomla defaults // Set up MokoSuiteClient guided tours and unpublish Joomla defaults
$this->setupGuidedTours(); $this->setupGuidedTours();
// Clean up orphaned empty-element rows and stale files from old DEFAULT '' bug
$this->cleanupEmptyElements();
// Mark MokoSuiteClient extensions as protected (prevents disable/uninstall at framework level) // Mark MokoSuiteClient extensions as protected (prevents disable/uninstall at framework level)
$this->protectExtensions(); $this->protectExtensions();
// Migrate all Moko update server URLs to new format
$this->migrateUpdateServerUrls();
// Clean up stale/duplicate update sites
$this->cleanupStaleUpdateSites();
// Restore download key saved in preflight // Restore download key saved in preflight
$this->restoreDownloadKey(); $this->restoreDownloadKey();
// Fix orphaned update records (extension_id=0)
$this->fixUpdateRecords();
// Trigger heartbeat registration // Trigger heartbeat registration
$this->sendHeartbeat(); $this->sendHeartbeat();
@@ -463,16 +464,6 @@ class Pkg_MokosuiteclientInstallerScript
{ {
try try
{ {
// Only enable if the plugin files actually exist on disk
$pluginDir = JPATH_PLUGINS . '/' . $group . '/' . $element;
if (!is_dir($pluginDir))
{
Log::add('Skipping enable for ' . $group . '/' . $element . ' — files not installed', Log::DEBUG, 'mokosuiteclient');
return;
}
$db = Factory::getDbo(); $db = Factory::getDbo();
$query = $db->getQuery(true) $query = $db->getQuery(true)
->update($db->quoteName('#__extensions')) ->update($db->quoteName('#__extensions'))
@@ -506,22 +497,7 @@ class Pkg_MokosuiteclientInstallerScript
if ($db->getAffectedRows() > 0) if ($db->getAffectedRows() > 0)
{ {
Log::add('Fixed empty element for plugin ' . $group . '/' . $element, Log::NOTICE, 'mokosuiteclient'); Log::add('Fixed empty element for plugin ' . $group . '/' . $element, Log::NOTICE, 'mokosuiteclient');
return;
} }
// Verify no row exists before inserting (prevent duplicates)
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__extensions'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($group))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
);
// No row exists — reinstallBrokenPlugins() will handle it via
// Joomla's Installer which properly registers the namespace
} }
catch (\Throwable $e) catch (\Throwable $e)
{ {
@@ -539,246 +515,6 @@ class Pkg_MokosuiteclientInstallerScript
* *
* @since 02.03.10 * @since 02.03.10
*/ */
private function cleanupEmptyElements(): void
{
try
{
$db = Factory::getDbo();
// 1. Delete orphaned MokoSuiteClient extension rows with empty element
$db->setQuery("DELETE FROM " . $db->quoteName('#__extensions')
. " WHERE " . $db->quoteName('element') . " = ''"
. " AND " . $db->quoteName('type') . " = " . $db->quote('plugin')
. " AND " . $db->quoteName('name') . " LIKE " . $db->quote('%MokoSuiteClient%'));
$db->execute();
$deleted = $db->getAffectedRows();
// Delete rows where element is the display name (spaces)
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' LIKE ' . $db->quote('% %'))
->where($db->quoteName('element') . ' LIKE ' . $db->quote('%mokosuiteclient%'))
);
$db->execute();
$deleted += $db->getAffectedRows();
if ($deleted > 0)
{
Log::add("Deleted {$deleted} orphaned plugin row(s)", Log::INFO, 'mokosuiteclient');
}
// Deduplicate: keep only the lowest extension_id per element+folder
$db->setQuery(
"DELETE e1 FROM " . $db->quoteName('#__extensions') . " e1"
. " INNER JOIN " . $db->quoteName('#__extensions') . " e2"
. " ON e1.element = e2.element AND e1.folder = e2.folder AND e1.type = e2.type"
. " AND e1.extension_id > e2.extension_id"
. " WHERE e1.element LIKE 'mokosuiteclient%' AND e1.type = 'plugin'"
);
$db->execute();
$deduped = $db->getAffectedRows();
if ($deduped > 0)
{
Log::add("Removed {$deduped} duplicate extension row(s)", Log::INFO, 'mokosuiteclient');
}
// 2. Clean up stale plugin files that leaked to group roots
$groupDirs = [JPATH_PLUGINS . '/system', JPATH_PLUGINS . '/task', JPATH_PLUGINS . '/webservices'];
foreach ($groupDirs as $groupDir)
{
foreach (['services', 'src', 'language'] as $dir)
{
$path = $groupDir . '/' . $dir;
if (is_dir($path))
{
$this->rmdirRecursive($path);
}
}
// Remove stale manifest XMLs at group root
foreach (glob($groupDir . '/mokosuiteclient*.xml') ?: [] as $staleXml)
{
@unlink($staleXml);
}
// Remove dirs with spaces (Joomla uses display name as dir)
foreach (glob($groupDir . '/*mokosuiteclient*', GLOB_ONLYDIR) ?: [] as $badDir)
{
if (strpos(basename($badDir), ' ') !== false)
{
$this->rmdirRecursive($badDir);
}
}
}
// 3. Reinstall plugins that are missing their directory
$this->reinstallBrokenPlugins();
}
catch (\Throwable $e)
{
Log::add('Empty element cleanup error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* Reinstall plugins whose files are missing from disk.
*
* Uses the sub-extension zip files from the package source directory
* (still available during postflight) to reinstall any plugin that
* doesn't have its directory on disk.
*/
private function reinstallBrokenPlugins(): void
{
if (!$this->installerParent)
{
return;
}
try
{
$parentInstaller = $this->installerParent->getParent();
$sourceDir = $parentInstaller->getPath('source');
if (empty($sourceDir) || !is_dir($sourceDir . '/packages'))
{
return;
}
// Plugins that should exist on disk
$expected = [
'system' => ['mokosuiteclient_offline', 'mokosuiteclient_firewall', 'mokosuiteclient_tenant', 'mokosuiteclient_devtools', 'mokosuiteclient_dbip'],
'task' => [],
];
foreach ($expected as $group => $elements)
{
foreach ($elements as $element)
{
$pluginDir = JPATH_PLUGINS . '/' . $group . '/' . $element;
$zipName = 'plg_' . $group . '_' . $element . '.zip';
$zipPath = $sourceDir . '/packages/' . $zipName;
if (!is_file($zipPath))
{
continue;
}
// Extract to plugin dir
$zip = new \ZipArchive();
if ($zip->open($zipPath) !== true)
{
continue;
}
if (is_dir($pluginDir))
{
$this->rmdirRecursive($pluginDir);
}
@mkdir($pluginDir, 0755, true);
$zip->extractTo($pluginDir);
$zip->close();
}
}
// Step 2: Create DB records for plugins that have files but no record
$db = Factory::getDbo();
foreach ($expected as $group => $elements)
{
foreach ($elements as $element)
{
$pluginDir = JPATH_PLUGINS . '/' . $group . '/' . $element;
$manifestFile = $pluginDir . '/' . $element . '.xml';
if (!is_file($manifestFile))
{
continue;
}
// Check if record exists
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__extensions'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($group))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
);
if ((int) $db->loadResult() > 0)
{
continue;
}
// Parse manifest for name and namespace
$xml = @simplexml_load_file($manifestFile);
$name = $xml ? (string) ($xml->name ?? '') : '';
$namespace = $xml ? (string) ($xml->namespace ?? '') : '';
$version = $xml ? (string) ($xml->version ?? '') : '';
if (empty($name))
{
$name = 'plg_' . $group . '_' . $element;
}
// Build manifest cache
$cache = json_encode([
'name' => $name,
'type' => 'plugin',
'version' => $version,
'group' => $group,
]);
// INSERT with all required fields including namespace
$columns = 'name, type, element, folder, client_id, enabled, access, protected, locked, params, manifest_cache, custom_data, state, ordering';
$values = $db->quote($name) . ', '
. $db->quote('plugin') . ', '
. $db->quote($element) . ', '
. $db->quote($group) . ', '
. '0, 1, 1, 0, 0, '
. $db->quote('{}') . ', '
. $db->quote($cache) . ', '
. $db->quote('') . ', 0, 0';
$sql = "INSERT INTO " . $db->quoteName('#__extensions')
. " ({$columns}) VALUES ({$values})";
$db->setQuery($sql)->execute();
$newId = $db->insertid();
// Set namespace if column exists
if (!empty($namespace))
{
try
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('namespace') . ' = ' . $db->quote($namespace))
->where($db->quoteName('extension_id') . ' = ' . (int) $newId)
)->execute();
}
catch (\Throwable $e)
{
// namespace column may not exist in this Joomla version
}
}
Log::add("Created extension record for {$group}/{$element} (ID {$newId})", Log::INFO, 'mokosuiteclient');
}
}
}
catch (\Throwable $e)
{
Log::add('Plugin reinstall error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
private function protectExtensions(): void private function protectExtensions(): void
{ {
try try
@@ -798,6 +534,8 @@ class Pkg_MokosuiteclientInstallerScript
$db->quote('mod_mokosuiteclient_cpanel'), $db->quote('mod_mokosuiteclient_cpanel'),
$db->quote('mokosuiteclientdemo'), $db->quote('mokosuiteclientdemo'),
$db->quote('mokosuiteclientsync'), $db->quote('mokosuiteclientsync'),
$db->quote('mokosuiteclient_tickets'),
$db->quote('mokosuiteclient_backup'),
$db->quote('mokoonyx'), $db->quote('mokoonyx'),
]; ];
@@ -809,6 +547,8 @@ class Pkg_MokosuiteclientInstallerScript
$db->setQuery($query); $db->setQuery($query);
$db->execute(); $db->execute();
// Ensure update server stays enabled
$this->enableUpdateServer();
} }
catch (\Throwable $e) catch (\Throwable $e)
{ {
@@ -1230,24 +970,19 @@ class Pkg_MokosuiteclientInstallerScript
if ($error) if ($error)
{ {
Log::add('Heartbeat connection failed: ' . $error, Log::WARNING, 'mokosuiteclient'); Log::add('Heartbeat connection failed: ' . $error, Log::WARNING, 'mokosuiteclient');
Factory::getApplication()->enqueueMessage('MokoSuiteHQ heartbeat failed: ' . $error, 'warning');
} }
elseif ($code >= 200 && $code < 300) elseif ($code >= 200 && $code < 300)
{ {
Factory::getApplication()->enqueueMessage('MokoSuiteHQ heartbeat: site registered successfully.', 'message'); Factory::getApplication()->enqueueMessage('MokoSuiteClientHQ heartbeat: site registered', 'message');
} }
else else
{ {
$body = json_decode($response, true);
$msg = $body['error'] ?? $body['message'] ?? ('HTTP ' . $code);
Log::add(sprintf('Heartbeat HTTP %d: %s', $code, $response), Log::WARNING, 'mokosuiteclient'); Log::add(sprintf('Heartbeat HTTP %d: %s', $code, $response), Log::WARNING, 'mokosuiteclient');
Factory::getApplication()->enqueueMessage('MokoSuiteHQ heartbeat failed: ' . $msg, 'warning');
} }
} }
catch (\Throwable $e) catch (\Throwable $e)
{ {
Log::add('Heartbeat failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); Log::add('Heartbeat failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
Factory::getApplication()->enqueueMessage('MokoSuiteHQ heartbeat failed: ' . $e->getMessage(), 'warning');
} }
} }
@@ -1519,6 +1254,17 @@ class Pkg_MokosuiteclientInstallerScript
['title' => 'IP Blocklist', 'desc' => 'Block specific IP addresses, CIDR ranges, or wildcard patterns. The auto-ban feature automatically blocks IPs that trigger too many WAF alerts.', 'target' => '', 'type' => 0], ['title' => 'IP Blocklist', 'desc' => 'Block specific IP addresses, CIDR ranges, or wildcard patterns. The auto-ban feature automatically blocks IPs that trigger too many WAF alerts.', 'target' => '', 'type' => 0],
], ],
], ],
[
'uid' => 'mokosuiteclient-helpdesk',
'title' => 'MokoSuiteClient Helpdesk',
'desc' => 'Learn how to manage support tickets, categories, and automation rules.',
'url' => 'administrator/index.php?option=com_mokosuiteclient&view=tickets',
'steps' => [
['title' => 'Ticket List', 'desc' => 'View all support tickets with status, priority, SLA tracking, and assignment. Filter by status or search to find specific tickets.', 'target' => '', 'type' => 0],
['title' => 'Create a Ticket', 'desc' => 'Click the New button to create a support ticket. Assign a category, priority, and optional SLA deadline.', 'target' => '', 'type' => 0],
['title' => 'Ticket Automation', 'desc' => 'Set up automation rules that trigger on ticket events (new ticket, status change) or Joomla events (user login, registration). Automate assignment, notifications, and status changes.', 'target' => '', 'type' => 0],
],
],
[ [
'uid' => 'mokosuiteclient-extensions', 'uid' => 'mokosuiteclient-extensions',
'title' => 'Moko Extensions Manager', 'title' => 'Moko Extensions Manager',
@@ -1604,8 +1350,6 @@ class Pkg_MokosuiteclientInstallerScript
*/ */
private function setupSupportMenuItem(): void private function setupSupportMenuItem(): void
{ {
// Tickets moved to MokoSuiteCRM — no frontend support menu needed
return;
try try
{ {
$db = Factory::getDbo(); $db = Factory::getDbo();