Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d12971c0b7 | |||
| 21156deb0e | |||
| 1547bd5861 | |||
| f66871db2e | |||
| c5aef3c939 | |||
| 0d96174f75 | |||
| 6acae6d20f | |||
| 9dacc01a67 | |||
| c7d914f786 | |||
| 729aa3850d | |||
| d4176836a5 | |||
| ef9d98ea04 | |||
| ffecdc4796 | |||
| f1dbc10e4d | |||
| 1289ef81b2 | |||
| 070df8982b | |||
| 99179ad245 | |||
| 0fccd3f1a4 | |||
| ef873bda3b | |||
| 3243ecba4a | |||
| b8083203e9 | |||
| 31a4d12ceb | |||
| 9fedffe570 | |||
| 47e3802293 | |||
| 76fe9ba311 | |||
| 806a798b87 | |||
| d899bf945e | |||
| 6892b6ac44 | |||
| 3a1fc7e4ac | |||
| c2a90265d2 | |||
| c9889d4abe | |||
| d306b01260 | |||
| 7e2476b250 | |||
| 6b195d0514 | |||
| fc1f3dd903 | |||
| 4f1b9ac3f2 | |||
| 188defdf1b | |||
| 3b972efcdc | |||
| 2fd3f04f79 | |||
| 2b1bbb9c94 | |||
| 34cf1235c2 | |||
| a8341d456d | |||
| d49fdd24fc | |||
| 47db66b70b | |||
| ca9ef82caf | |||
| b7057745a3 | |||
| 6d3eaa4471 | |||
| 4237740d32 | |||
| f79dc2a26e | |||
| 903999a262 | |||
| d4514aa37d | |||
| 723f25bb59 | |||
| 1522416287 |
@@ -122,7 +122,6 @@ build/
|
||||
dist/
|
||||
out/
|
||||
site/
|
||||
!src/packages/*/site/
|
||||
*.map
|
||||
*.css.map
|
||||
*.js.map
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<display-name>Package - MokoWaaS</display-name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>White-label identity, security hardening, and tenant restriction layer for WaaS-managed Joomla environments</description>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.00</version>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
|
||||
@@ -102,13 +102,14 @@ jobs:
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--skip-update-stream
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
release:
|
||||
@@ -167,7 +168,8 @@ jobs:
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
--path . --stability stable --bump minor --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--skip-update-stream
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 02.32.38
|
||||
# VERSION: 02.34.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -147,6 +147,98 @@ jobs:
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Joomla JEXEC guard check
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
# Skip vendor, node_modules, and index.html stub files
|
||||
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
|
||||
# Check first 10 lines for JEXEC or JPATH guard
|
||||
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
|
||||
echo "::error file=${file}::Missing JEXEC guard: ${file}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
|
||||
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "JEXEC guard: OK"
|
||||
|
||||
- name: Joomla directory listing protection
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
MISSING=0
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
while IFS= read -r dir; do
|
||||
if [ ! -f "${dir}/index.html" ]; then
|
||||
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
|
||||
if [ "$MISSING" -gt 0 ]; then
|
||||
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "Directory protection: ${MISSING} missing (advisory)"
|
||||
|
||||
- name: Joomla script file and asset checks
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && exit 0
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
# Check scriptfile exists if declared
|
||||
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
|
||||
if [ -n "$SCRIPTFILE" ]; then
|
||||
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
|
||||
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Require joomla.asset.json and validate it
|
||||
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$ASSET_JSON" ]; then
|
||||
echo "::error::joomla.asset.json not found — Joomla asset system is required"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
|
||||
echo "::error::joomla.asset.json is not valid JSON"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
}
|
||||
fi
|
||||
echo "joomla.asset.json: valid"
|
||||
fi
|
||||
|
||||
# Validate all XML files in src/ are well-formed
|
||||
XML_ERRORS=0
|
||||
if command -v php &> /dev/null; then
|
||||
while IFS= read -r -d '' xmlfile; do
|
||||
if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
|
||||
XML_ERRORS=$((XML_ERRORS + 1))
|
||||
fi
|
||||
done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
|
||||
fi
|
||||
if [ "$XML_ERRORS" -gt 0 ]; then
|
||||
echo "::error::${XML_ERRORS} XML file(s) are malformed"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "XML well-formedness: OK"
|
||||
fi
|
||||
|
||||
[ "$ERRORS" -gt 0 ] && exit 1
|
||||
echo "Joomla asset checks: OK"
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
@@ -164,6 +256,13 @@ jobs:
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
# Block legacy raw/branch update server URLs on MokoGitea
|
||||
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
|
||||
if [ -n "$RAW_URLS" ]; then
|
||||
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
|
||||
echo "$RAW_URLS"
|
||||
exit 1
|
||||
fi
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
@@ -196,6 +295,138 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Validate Joomla language files
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
|
||||
# Require both en-GB and en-US language directories
|
||||
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$LANG_ROOT" ]; then
|
||||
echo "No language/ directory found — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "$LANG_ROOT/en-GB" ]; then
|
||||
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
if [ ! -d "$LANG_ROOT/en-US" ]; then
|
||||
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
# Check that en-GB and en-US have matching .ini files
|
||||
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
|
||||
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
|
||||
[ ! -f "$GB_INI" ] && continue
|
||||
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
|
||||
if [ ! -f "$US_INI" ]; then
|
||||
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
|
||||
[ ! -f "$US_INI" ] && continue
|
||||
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
|
||||
if [ ! -f "$GB_INI" ]; then
|
||||
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Find all .ini language files
|
||||
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
|
||||
if [ -z "$INI_FILES" ]; then
|
||||
echo "No .ini language files found"
|
||||
[ "$ERRORS" -gt 0 ] && exit 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
|
||||
|
||||
for FILE in $INI_FILES; do
|
||||
FNAME=$(basename "$FILE")
|
||||
LINENUM=0
|
||||
SEEN_KEYS=""
|
||||
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
LINENUM=$((LINENUM + 1))
|
||||
|
||||
# Skip empty lines and comments
|
||||
[ -z "$line" ] && continue
|
||||
echo "$line" | grep -qE '^\s*;' && continue
|
||||
echo "$line" | grep -qE '^\s*$' && continue
|
||||
|
||||
# Must match KEY="VALUE" format
|
||||
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
|
||||
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract key and check for duplicates
|
||||
KEY=$(echo "$line" | sed 's/=.*//')
|
||||
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
|
||||
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
SEEN_KEYS="${SEEN_KEYS}
|
||||
${KEY}"
|
||||
done < "$FILE"
|
||||
|
||||
echo " ${FILE}: checked ${LINENUM} lines"
|
||||
done
|
||||
|
||||
# Cross-check en-GB vs en-US key consistency
|
||||
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
|
||||
for GB_FILE in "$GB_DIR"/*.ini; do
|
||||
[ ! -f "$GB_FILE" ] && continue
|
||||
FNAME=$(basename "$GB_FILE")
|
||||
US_FILE="$US_DIR/$FNAME"
|
||||
[ ! -f "$US_FILE" ] && continue
|
||||
|
||||
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
|
||||
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
|
||||
|
||||
# Keys in en-GB but not en-US
|
||||
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||
if [ -n "$MISSING_US" ]; then
|
||||
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
|
||||
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
|
||||
# Keys in en-US but not en-GB
|
||||
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||
if [ -n "$MISSING_GB" ]; then
|
||||
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
|
||||
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
{
|
||||
echo "### Language File Validation"
|
||||
echo "| Metric | Count |"
|
||||
echo "|---|---|"
|
||||
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
|
||||
echo "| Errors | ${ERRORS} |"
|
||||
echo "| Warnings | ${WARNINGS} |"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "::error::Language validation failed with ${ERRORS} error(s)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Language files: OK (${WARNINGS} warning(s))"
|
||||
|
||||
- name: Check changelog has unreleased entry
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
@@ -17,6 +17,10 @@ on:
|
||||
types: [closed]
|
||||
branches:
|
||||
- dev
|
||||
pull_request_target:
|
||||
types: [synchronize, opened, reopened]
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
@@ -43,7 +47,8 @@ jobs:
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') ||
|
||||
(github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == 'main')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -51,7 +56,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
submodules: recursive
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
@@ -61,7 +66,7 @@ jobs:
|
||||
if ! command -v composer &> /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
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
@@ -77,25 +82,38 @@ jobs:
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
# Auto-detect stability: RC for PRs targeting main, else use input or default to development
|
||||
if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then
|
||||
STABILITY="release-candidate"
|
||||
else
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
fi
|
||||
|
||||
case "$STABILITY" in
|
||||
development) TAG="development" ;;
|
||||
alpha) TAG="alpha" ;;
|
||||
beta) TAG="beta" ;;
|
||||
release-candidate) TAG="release-candidate" ;;
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Set stability suffix, bump preserves it, fix consistency
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \
|
||||
--branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Read final version (includes suffix, e.g. 01.02.15-dev)
|
||||
# Read current version (bump already handled by push workflow)
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
|
||||
[ -z "$VERSION" ] && VERSION="00.00.01"
|
||||
|
||||
# Strip any existing suffix from version before applying stability
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
|
||||
|
||||
# Verify version consistency across all files
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Update VERSION variable with suffix
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${VERSION}${SUFFIX}"
|
||||
fi
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
@@ -119,11 +137,12 @@ jobs:
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION} ==="
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||
|
||||
- name: Create release
|
||||
id: release
|
||||
@@ -136,6 +155,21 @@ jobs:
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch dev --prerelease
|
||||
|
||||
- name: Ensure prerelease flag
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
# Get release ID by tag and force prerelease=true
|
||||
RELEASE_ID=$(curl -s "${API_BASE}/releases/tags/${TAG}" \
|
||||
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" | jq -r '.id // empty')
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
curl -s -X PATCH "${API_BASE}/releases/${RELEASE_ID}" \
|
||||
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"prerelease": true}'
|
||||
echo "Marked release ${TAG} (id=${RELEASE_ID}) as prerelease"
|
||||
fi
|
||||
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
run: |
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
|
||||
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
|
||||
# ============================================================================
|
||||
|
||||
name: "Generic: Repo Health"
|
||||
@@ -24,13 +24,12 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: 'Validation profile: all, release, scripts, or repo'
|
||||
description: 'Validation profile: all, scripts, or repo'
|
||||
required: true
|
||||
default: all
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- release
|
||||
- scripts
|
||||
- repo
|
||||
pull_request:
|
||||
@@ -40,10 +39,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Release policy - Repository Variables Only
|
||||
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
|
||||
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
|
||||
|
||||
# Scripts governance policy
|
||||
SCRIPTS_REQUIRED_DIRS:
|
||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||
@@ -138,101 +133,6 @@ jobs:
|
||||
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
|
||||
release_config:
|
||||
name: Release configuration
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Guardrails release vars
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
|
||||
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes release validation'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
|
||||
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
|
||||
|
||||
missing=()
|
||||
missing_optional=()
|
||||
|
||||
for k in "${required[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing+=("${k}")
|
||||
done
|
||||
|
||||
for k in "${optional[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing_optional+=("${k}")
|
||||
done
|
||||
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Variable | Status |'
|
||||
printf '%s\n' '|---|---|'
|
||||
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
|
||||
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing optional repository variables'
|
||||
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing required repository variables'
|
||||
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository variables validation result'
|
||||
printf '%s\n' 'Status: OK'
|
||||
printf '%s\n' 'All required repository variables present.'
|
||||
printf '%s\n' ''
|
||||
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
scripts_governance:
|
||||
name: Scripts governance
|
||||
needs: access_check
|
||||
@@ -256,14 +156,14 @@ jobs:
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
all|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
|
||||
if [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
@@ -370,14 +270,14 @@ jobs:
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
all|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
|
||||
if [ "${profile}" = 'scripts' ]; then
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
@@ -704,7 +604,7 @@ jobs:
|
||||
printf '%s\n' '| Domain | Status | Notes |'
|
||||
printf '%s\n' '|---|---|---|'
|
||||
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
|
||||
printf '%s\n' '| Release variables | OK | Repository variables validation |'
|
||||
printf '%s\n' '| Release policy | N/A | Releases handled by MokoGitea |'
|
||||
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
|
||||
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
|
||||
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
|
||||
@@ -773,11 +673,10 @@ jobs:
|
||||
report-issues:
|
||||
name: "Report Issues"
|
||||
runs-on: ubuntu-latest
|
||||
needs: [access_check, release_config, scripts_governance, repo_health]
|
||||
needs: [access_check, scripts_governance, repo_health]
|
||||
if: >-
|
||||
always() &&
|
||||
(needs.release_config.result == 'failure' ||
|
||||
needs.scripts_governance.result == 'failure' ||
|
||||
(needs.scripts_governance.result == 'failure' ||
|
||||
needs.repo_health.result == 'failure')
|
||||
|
||||
steps:
|
||||
@@ -803,10 +702,6 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
report_gate "Release Configuration" \
|
||||
"${{ needs.release_config.result }}" \
|
||||
"Required repository variables are missing (RS_FTP_PATH_SUFFIX). Check repository settings."
|
||||
|
||||
report_gate "Scripts Governance" \
|
||||
"${{ needs.scripts_governance.result }}" \
|
||||
"Scripts directory policy violations detected. Review required and allowed directories."
|
||||
@@ -814,4 +709,3 @@ jobs:
|
||||
report_gate "Repository Health" \
|
||||
"${{ needs.repo_health.result }}" \
|
||||
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
|
||||
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/update-server.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
|
||||
#
|
||||
# Thin wrapper around moko-platform CLI tools.
|
||||
# Builds packages, updates updates.xml, and optionally deploys via SFTP.
|
||||
#
|
||||
# Joomla filters update entries by the user's "Minimum Stability" setting.
|
||||
|
||||
name: "Update Server"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'dev/**'
|
||||
- 'alpha/**'
|
||||
- 'beta/**'
|
||||
- 'rc/**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'dev/**'
|
||||
- 'alpha/**'
|
||||
- 'beta/**'
|
||||
- 'rc/**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Stability tag'
|
||||
required: true
|
||||
default: 'development'
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- rc
|
||||
- stable
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-xml:
|
||||
name: Update Server
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}'
|
||||
run: |
|
||||
if ! command -v composer &> /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
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform 2>/dev/null || true
|
||||
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
|
||||
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve stability and bump version
|
||||
id: meta
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
|
||||
# Configure git for bot pushes
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
# Determine stability from branch or manual input
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
elif [[ "$BRANCH" == rc/* ]]; then
|
||||
STABILITY="rc"
|
||||
elif [[ "$BRANCH" == beta/* ]]; then
|
||||
STABILITY="beta"
|
||||
elif [[ "$BRANCH" == alpha/* ]]; then
|
||||
STABILITY="alpha"
|
||||
else
|
||||
STABILITY="development"
|
||||
fi
|
||||
|
||||
# Gitea release tag per stability
|
||||
case "$STABILITY" in
|
||||
development) TAG="development" ;;
|
||||
alpha) TAG="alpha" ;;
|
||||
beta) TAG="beta" ;;
|
||||
rc) TAG="release-candidate" ;;
|
||||
*) TAG="stable" ;;
|
||||
esac
|
||||
|
||||
# Bump patch, set platform suffix, fix consistency — version_bump preserves suffix
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \
|
||||
--branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
|
||||
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Read final version (includes suffix, e.g. 01.02.15-dev)
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Commit version bump if changed
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||
git push
|
||||
}
|
||||
|
||||
- name: Create release and upload package
|
||||
id: package
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Create or update Gitea release
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
|
||||
# Build package and upload
|
||||
php ${MOKO_CLI}/release_package.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --output /tmp || true
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHA_FLAG=""
|
||||
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
|
||||
|
||||
php ${MOKO_CLI}/updates_xml_build.php \
|
||||
--path . --version "${VERSION}" --stability "${STABILITY}" \
|
||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
${SHA_FLAG}
|
||||
|
||||
# Commit and push updates.xml
|
||||
git add updates.xml
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push
|
||||
}
|
||||
|
||||
- name: Sync updates.xml to main
|
||||
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
|
||||
python3 -c "
|
||||
import base64, json, urllib.request, sys
|
||||
with open('updates.xml', 'rb') as f:
|
||||
content = base64.b64encode(f.read()).decode()
|
||||
payload = json.dumps({
|
||||
'content': content,
|
||||
'sha': '${FILE_SHA}',
|
||||
'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]',
|
||||
'branch': 'main'
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/contents/updates.xml',
|
||||
data=payload, method='PUT',
|
||||
headers={
|
||||
'Authorization': 'token ${GITEA_TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
print('updates.xml synced to main')
|
||||
except Exception as e:
|
||||
print(f'WARNING: sync to main failed: {e}', file=sys.stderr)
|
||||
"
|
||||
fi
|
||||
|
||||
- name: SFTP deploy to dev server
|
||||
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
|
||||
env:
|
||||
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
|
||||
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
run: |
|
||||
# Permission check: admin or maintain role required
|
||||
ACTOR="${{ github.actor }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
|
||||
case "$PERMISSION" in
|
||||
admin|maintain|write) ;;
|
||||
*)
|
||||
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
|
||||
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
|
||||
PORT="${DEV_PORT:-22}"
|
||||
REMOTE="${DEV_PATH%/}"
|
||||
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
|
||||
if [ -n "$DEV_KEY" ]; then
|
||||
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then
|
||||
php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then
|
||||
php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
fi
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
DISPLAY="${VERSION}"
|
||||
echo "## Update Server" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
+2
-13
@@ -14,12 +14,11 @@
|
||||
INGROUP: MokoWaaS.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
PATH: ./CHANGELOG.md
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
BRIEF: Version history using `Keep a Changelog`
|
||||
-->
|
||||
|
||||
# Changelog
|
||||
## [02.32.00] - 2026-06-02
|
||||
# Changelog## [02.32.00] - 2026-06-02
|
||||
### Added
|
||||
- Admin control panel dashboard in com_mokowaas with site info bar, feature plugin grid, and quick actions
|
||||
- Feature plugin architecture — MokoWaaS features split into toggleable plugins managed from the dashboard
|
||||
@@ -108,13 +107,3 @@
|
||||
## [02.20.00] --- 2026-05-28
|
||||
|
||||
## [02.20.00] --- 2026-05-28
|
||||
|
||||
## [02.19.00] --- 2026-05-28
|
||||
|
||||
## [02.18.00] --- 2026-05-28
|
||||
|
||||
|
||||
All notable changes to the MokoWaaS plugin will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
PATH: ./CODE_OF_CONDUCT.md
|
||||
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
||||
-->
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
DEFGROUP: mokoconsulting-tech.MokoWaaSBrand
|
||||
INGROUP: MokoStandards.Governance
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoWaaSBrand
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
PATH: /GOVERNANCE.md
|
||||
BRIEF: Project governance rules, roles, and decision process for MokoWaaSBrand
|
||||
-->
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
INGROUP: MokoWaaS.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
PATH: ./LICENSE.md
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
BRIEF: Project license (GPL-3.0-or-later)
|
||||
-->
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
PATH: /README.md
|
||||
BRIEF: MokoWaaS platform plugin for Joomla
|
||||
-->
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
|
||||
INGROUP: [PROJECT_NAME].Documentation
|
||||
REPO: [REPOSITORY_URL]
|
||||
PATH: /SECURITY.md
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
BRIEF: Security vulnerability reporting and handling policy
|
||||
-->
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
INGROUP: MokoWaaS.Build
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
FILE: build-guide.md
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
PATH: /docs/guides/
|
||||
BRIEF: Build and packaging guide for the MokoWaaS system plugin
|
||||
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
|
||||
-->
|
||||
|
||||
# MokoWaaS Build Guide (VERSION: 02.32.38)
|
||||
# MokoWaaS Build Guide (VERSION: 02.34.00)
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
PATH: /docs/guides/configuration-guide.md
|
||||
BRIEF: Configuration guide for the MokoWaaS system plugin
|
||||
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
|
||||
-->
|
||||
|
||||
# MokoWaaS Configuration Guide (VERSION: 02.32.38)
|
||||
# MokoWaaS Configuration Guide (VERSION: 02.34.00)
|
||||
|
||||
## 1. Objective
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
PATH: /docs/guides/installation-guide.md
|
||||
BRIEF: Installation guide for the MokoWaaS system plugin
|
||||
NOTE: First document in the guide set
|
||||
-->
|
||||
|
||||
# MokoWaaS Installation Guide (VERSION: 02.32.38)
|
||||
# MokoWaaS Installation Guide (VERSION: 02.34.00)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
PATH: /docs/guides/operations-guide.md
|
||||
BRIEF: Operational guide for administering and managing the MokoWaaS system plugin
|
||||
NOTE: Defines lifecycle, responsibilities, and operational behaviors
|
||||
-->
|
||||
|
||||
# MokoWaaS Operations Guide (VERSION: 02.32.38)
|
||||
# MokoWaaS Operations Guide (VERSION: 02.34.00)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
PATH: /docs/guides/rollback-and-recovery-guide.md
|
||||
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
|
||||
NOTE: Completes the core guide set for WaaS plugin governance
|
||||
-->
|
||||
|
||||
# MokoWaaS Rollback and Recovery Guide (VERSION: 02.32.38)
|
||||
# MokoWaaS Rollback and Recovery Guide (VERSION: 02.34.00)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
PATH: /docs/guides/testing-guide.md
|
||||
BRIEF: Testing guide for MokoWaaS v02.01.08
|
||||
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
|
||||
-->
|
||||
|
||||
# MokoWaaS Testing Guide (VERSION: 02.32.38)
|
||||
# MokoWaaS Testing Guide (VERSION: 02.34.00)
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
PATH: /docs/guides/troubleshooting-guide.md
|
||||
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoWaaS plugin
|
||||
NOTE: Designed for administrators and WaaS operations teams
|
||||
-->
|
||||
|
||||
# MokoWaaS Troubleshooting Guide (VERSION: 02.32.38)
|
||||
# MokoWaaS Troubleshooting Guide (VERSION: 02.34.00)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
PATH: /docs/guides/upgrade-and-versioning-guide.md
|
||||
BRIEF: Guide for updating, versioning, and maintaining the MokoWaaS plugin
|
||||
NOTE: Defines release flow, version rules, and upgrade validation
|
||||
-->
|
||||
|
||||
# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.32.38)
|
||||
# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.34.00)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
+2
-2
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
PATH: /docs/index.md
|
||||
BRIEF: Master index of all documentation for the MokoWaaS plugin
|
||||
NOTE: Automatically maintained index for all guide canvases
|
||||
-->
|
||||
|
||||
# MokoWaaS Documentation Index (VERSION: 02.32.38)
|
||||
# MokoWaaS Documentation Index (VERSION: 02.34.00)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
INGROUP: MokoWaaS
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
PATH: /docs/plugin-basic.md
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
BRIEF: Baseline documentation for the MokoWaaS system plugin
|
||||
NOTE: Foundational reference for internal and external stakeholders
|
||||
-->
|
||||
|
||||
# MokoWaaS Plugin Overview (VERSION: 02.32.38)
|
||||
# MokoWaaS Plugin Overview (VERSION: 02.34.00)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ DEFGROUP: MokoWaaS.Documentation
|
||||
INGROUP: MokoStandards.Templates
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoWaaS
|
||||
PATH: /docs/update-server.md
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.00
|
||||
BRIEF: How this extension's Joomla update server file (update.xml) is managed
|
||||
-->
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<access component="com_mokowaas">
|
||||
<section name="component">
|
||||
<action name="core.admin" title="JACTION_ADMIN" description="JACTION_ADMIN_COMPONENT_DESC" />
|
||||
<action name="core.manage" title="JACTION_MANAGE" description="JACTION_MANAGE_COMPONENT_DESC" />
|
||||
<action name="mokowaas.dashboard" title="COM_MOKOWAAS_ACL_DASHBOARD" description="COM_MOKOWAAS_ACL_DASHBOARD_DESC" />
|
||||
<action name="mokowaas.extensions" title="COM_MOKOWAAS_ACL_EXTENSIONS" description="COM_MOKOWAAS_ACL_EXTENSIONS_DESC" />
|
||||
<action name="mokowaas.htaccess" title="COM_MOKOWAAS_ACL_HTACCESS" description="COM_MOKOWAAS_ACL_HTACCESS_DESC" />
|
||||
<action name="mokowaas.tickets" title="COM_MOKOWAAS_ACL_TICKETS" description="COM_MOKOWAAS_ACL_TICKETS_DESC" />
|
||||
<action name="mokowaas.tickets.create" title="COM_MOKOWAAS_ACL_TICKETS_CREATE" description="COM_MOKOWAAS_ACL_TICKETS_CREATE_DESC" />
|
||||
<action name="mokowaas.tickets.assign" title="COM_MOKOWAAS_ACL_TICKETS_ASSIGN" description="COM_MOKOWAAS_ACL_TICKETS_ASSIGN_DESC" />
|
||||
<action name="mokowaas.plugins.toggle" title="COM_MOKOWAAS_ACL_PLUGINS_TOGGLE" description="COM_MOKOWAAS_ACL_PLUGINS_TOGGLE_DESC" />
|
||||
<action name="mokowaas.cache" title="COM_MOKOWAAS_ACL_CACHE" description="COM_MOKOWAAS_ACL_CACHE_DESC" />
|
||||
</section>
|
||||
</access>
|
||||
@@ -19,23 +19,3 @@ COM_MOKOWAAS_CACHE_CLEARED="Cache cleared successfully."
|
||||
COM_MOKOWAAS_EXTENSIONS_TITLE="Moko Extensions"
|
||||
COM_MOKOWAAS_EXTENSIONS_INFO="Install Moko Consulting Joomla packages from the official release server. Updates are handled through Joomla's native System > Update mechanism — each package registers its own update server."
|
||||
COM_MOKOWAAS_EXTENSIONS_LINK="Moko Extensions"
|
||||
COM_MOKOWAAS_HTACCESS_TITLE=".htaccess Maker"
|
||||
COM_MOKOWAAS_TICKETS_TITLE="Helpdesk"
|
||||
|
||||
; ACL
|
||||
COM_MOKOWAAS_ACL_DASHBOARD="View Dashboard"
|
||||
COM_MOKOWAAS_ACL_DASHBOARD_DESC="Allow viewing the MokoWaaS control panel dashboard."
|
||||
COM_MOKOWAAS_ACL_EXTENSIONS="Manage Extensions"
|
||||
COM_MOKOWAAS_ACL_EXTENSIONS_DESC="Allow installing and uninstalling Moko extensions."
|
||||
COM_MOKOWAAS_ACL_HTACCESS="Manage .htaccess"
|
||||
COM_MOKOWAAS_ACL_HTACCESS_DESC="Allow editing and saving the .htaccess configuration."
|
||||
COM_MOKOWAAS_ACL_TICKETS="View Tickets"
|
||||
COM_MOKOWAAS_ACL_TICKETS_DESC="Allow viewing helpdesk tickets."
|
||||
COM_MOKOWAAS_ACL_TICKETS_CREATE="Create Tickets"
|
||||
COM_MOKOWAAS_ACL_TICKETS_CREATE_DESC="Allow creating new helpdesk tickets."
|
||||
COM_MOKOWAAS_ACL_TICKETS_ASSIGN="Assign Tickets"
|
||||
COM_MOKOWAAS_ACL_TICKETS_ASSIGN_DESC="Allow assigning tickets to other users."
|
||||
COM_MOKOWAAS_ACL_PLUGINS_TOGGLE="Toggle Plugins"
|
||||
COM_MOKOWAAS_ACL_PLUGINS_TOGGLE_DESC="Allow enabling and disabling MokoWaaS feature plugins."
|
||||
COM_MOKOWAAS_ACL_CACHE="Clear Cache"
|
||||
COM_MOKOWAAS_ACL_CACHE_DESC="Allow clearing the Joomla cache from the dashboard."
|
||||
|
||||
@@ -5,11 +5,3 @@
|
||||
COM_MOKOWAAS="MokoWaaS"
|
||||
COM_MOKOWAAS_DESCRIPTION="MokoWaaS admin dashboard and REST API. Control panel for managing site features, health monitoring, and remote management."
|
||||
COM_MOKOWAAS_DASHBOARD_TITLE="MokoWaaS Control Panel"
|
||||
COM_MOKOWAAS_MENU_DASHBOARD="Dashboard"
|
||||
COM_MOKOWAAS_MENU_EXTENSIONS="Moko Extensions"
|
||||
COM_MOKOWAAS_MENU_PLUGINS="Feature Plugins"
|
||||
COM_MOKOWAAS_MENU_UPDATES="Joomla Updates"
|
||||
COM_MOKOWAAS_MENU_CHECKIN="Global Check-in"
|
||||
COM_MOKOWAAS_MENU_TICKETS="Helpdesk"
|
||||
COM_MOKOWAAS_MENU_HTACCESS=".htaccess Maker"
|
||||
COM_MOKOWAAS_MENU_CACHE="Cache Management"
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
--
|
||||
-- MokoWaaS Helpdesk Tables
|
||||
--
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_categories` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`alias` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`description` TEXT,
|
||||
`auto_assign_user` INT DEFAULT NULL,
|
||||
`sla_response_minutes` INT UNSIGNED NOT NULL DEFAULT 480,
|
||||
`sla_resolution_minutes` INT UNSIGNED NOT NULL DEFAULT 2880,
|
||||
`ordering` INT NOT NULL DEFAULT 0,
|
||||
`published` TINYINT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_alias` (`alias`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokowaas_tickets` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`subject` VARCHAR(512) NOT NULL,
|
||||
`body` TEXT NOT NULL,
|
||||
`status` ENUM('open','in_progress','waiting','resolved','closed') NOT NULL DEFAULT 'open',
|
||||
`priority` ENUM('low','normal','high','urgent') NOT NULL DEFAULT 'normal',
|
||||
`category_id` INT UNSIGNED DEFAULT NULL,
|
||||
`created_by` INT NOT NULL DEFAULT 0,
|
||||
`assigned_to` INT DEFAULT NULL,
|
||||
`created` DATETIME NOT NULL,
|
||||
`modified` DATETIME DEFAULT NULL,
|
||||
`resolved` DATETIME DEFAULT NULL,
|
||||
`closed` DATETIME DEFAULT NULL,
|
||||
`sla_response_due` DATETIME DEFAULT NULL,
|
||||
`sla_resolution_due` DATETIME DEFAULT NULL,
|
||||
`sla_responded` TINYINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_priority` (`priority`),
|
||||
KEY `idx_assigned` (`assigned_to`),
|
||||
KEY `idx_category` (`category_id`),
|
||||
KEY `idx_created` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_replies` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`ticket_id` INT UNSIGNED NOT NULL,
|
||||
`user_id` INT NOT NULL DEFAULT 0,
|
||||
`body` TEXT NOT NULL,
|
||||
`is_internal` TINYINT NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_ticket` (`ticket_id`),
|
||||
KEY `idx_created` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_canned` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`body` TEXT NOT NULL,
|
||||
`category_id` INT UNSIGNED DEFAULT NULL,
|
||||
`ordering` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_automation` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`trigger_event` VARCHAR(50) NOT NULL DEFAULT 'ticket_created',
|
||||
`conditions` TEXT NOT NULL DEFAULT '[]',
|
||||
`actions` TEXT NOT NULL DEFAULT '[]',
|
||||
`enabled` TINYINT NOT NULL DEFAULT 1,
|
||||
`ordering` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Default automation rules
|
||||
INSERT IGNORE INTO `#__mokowaas_ticket_automation` (`id`, `title`, `trigger_event`, `conditions`, `actions`, `enabled`, `ordering`) VALUES
|
||||
(1, 'Auto-close resolved tickets after 7 days', 'scheduled', '[{"field":"status","op":"eq","value":"resolved"},{"field":"age_hours","op":"gt","value":"168"}]', '[{"type":"set_status","value":"closed"},{"type":"add_note","value":"Auto-closed after 7 days with no response."}]', 1, 1),
|
||||
(2, 'Escalate urgent tickets with no response in 1 hour', 'scheduled', '[{"field":"priority","op":"eq","value":"urgent"},{"field":"sla_responded","op":"eq","value":"0"},{"field":"age_hours","op":"gt","value":"1"}]', '[{"type":"add_note","value":"SLA BREACH: Urgent ticket has no staff response after 1 hour."}]', 1, 2),
|
||||
(3, 'Notify on high priority ticket creation', 'ticket_created', '[{"field":"priority","op":"in","value":"high,urgent"}]', '[{"type":"add_note","value":"High/urgent ticket created — requires immediate attention."}]', 1, 3);
|
||||
|
||||
-- Default categories
|
||||
INSERT IGNORE INTO `#__mokowaas_ticket_categories` (`id`, `title`, `alias`, `description`, `sla_response_minutes`, `sla_resolution_minutes`, `ordering`) VALUES
|
||||
(1, 'General Support', 'general-support', 'General questions and assistance', 480, 2880, 1),
|
||||
(2, 'Bug Report', 'bug-report', 'Report a software bug or issue', 240, 1440, 2),
|
||||
(3, 'Feature Request', 'feature-request', 'Request a new feature or enhancement', 1440, 10080, 3),
|
||||
(4, 'Billing', 'billing', 'Billing, invoicing, and payment questions', 240, 1440, 4),
|
||||
(5, 'Urgent / Outage', 'urgent-outage', 'Site down or critical issue', 60, 240, 5);
|
||||
@@ -20,318 +20,106 @@ class DisplayController extends BaseController
|
||||
{
|
||||
protected $default_view = 'dashboard';
|
||||
|
||||
/**
|
||||
* ACL map: view name => required permission.
|
||||
*/
|
||||
private const VIEW_ACL = [
|
||||
'dashboard' => 'mokowaas.dashboard',
|
||||
'extensions' => 'mokowaas.extensions',
|
||||
'htaccess' => 'mokowaas.htaccess',
|
||||
'tickets' => 'mokowaas.tickets',
|
||||
'ticket' => 'mokowaas.tickets',
|
||||
];
|
||||
|
||||
public function display($cachable = false, $urlparams = [])
|
||||
{
|
||||
$view = $this->input->get('view', $this->default_view);
|
||||
$acl = self::VIEW_ACL[$view] ?? 'core.manage';
|
||||
|
||||
if (!$this->checkAcl($acl))
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
Factory::getApplication()->redirect(Route::_('index.php', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return parent::display($cachable, $urlparams);
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Plugin toggle
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Toggle a MokoWaaS feature plugin on or off.
|
||||
*
|
||||
* Expects POST with extension_id and enabled (0 or 1).
|
||||
* Returns JSON response for AJAX calls.
|
||||
*/
|
||||
public function togglePlugin()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.plugins.toggle'))
|
||||
$app = Factory::getApplication();
|
||||
$input = $app->getInput();
|
||||
|
||||
$user = $app->getIdentity();
|
||||
if (!$user->authorise('core.manage', 'com_plugins'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$model = $this->getModel('Dashboard');
|
||||
$extensionId = $input->getInt('extension_id', 0);
|
||||
$enabled = $input->getInt('enabled', 0);
|
||||
|
||||
$result = $model->togglePlugin(
|
||||
$app->getInput()->getInt('extension_id', 0),
|
||||
$app->getInput()->getInt('enabled', 0)
|
||||
);
|
||||
if (!$extensionId)
|
||||
{
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Missing extension_id']);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
$this->jsonResponse($result);
|
||||
$model = $this->getModel('Dashboard');
|
||||
$result = $model->togglePlugin($extensionId, $enabled);
|
||||
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode($result);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Cache
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Clear the Joomla cache.
|
||||
*/
|
||||
public function clearCache()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.cache'))
|
||||
$app = Factory::getApplication();
|
||||
$user = $app->getIdentity();
|
||||
|
||||
if (!$user->authorise('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
$this->jsonResponse($this->getModel('Dashboard')->clearCache());
|
||||
$model = $this->getModel('Dashboard');
|
||||
$result = $model->clearCache();
|
||||
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode($result);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Extensions
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Install a Moko extension from a download URL.
|
||||
*/
|
||||
public function installExtension()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.extensions'))
|
||||
$app = Factory::getApplication();
|
||||
$user = $app->getIdentity();
|
||||
|
||||
if (!$user->authorise('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
$downloadUrl = Factory::getApplication()->getInput()->getString('download_url', '');
|
||||
$downloadUrl = $app->getInput()->getString('download_url', '');
|
||||
|
||||
if (empty($downloadUrl))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Missing download URL.']);
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Missing download URL.']);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
$this->jsonResponse($this->getModel('Extensions')->installFromUrl($downloadUrl));
|
||||
}
|
||||
$model = $this->getModel('Extensions');
|
||||
$result = $model->installFromUrl($downloadUrl);
|
||||
|
||||
// ==================================================================
|
||||
// .htaccess
|
||||
// ==================================================================
|
||||
|
||||
public function saveHtaccess()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.htaccess'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
}
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$input = $app->getInput();
|
||||
$model = $this->getModel('Htaccess');
|
||||
|
||||
$options = [];
|
||||
|
||||
foreach ($input->getArray() as $key => $value)
|
||||
{
|
||||
if (str_starts_with($key, 'opt_'))
|
||||
{
|
||||
$options[substr($key, 4)] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($options))
|
||||
{
|
||||
$model->saveOptions($options);
|
||||
}
|
||||
|
||||
$this->jsonResponse($model->saveHtaccess($input->getRaw('content', '')));
|
||||
}
|
||||
|
||||
public function generateHtaccess()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.htaccess'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
}
|
||||
|
||||
$model = $this->getModel('Htaccess');
|
||||
$options = Factory::getApplication()->getInput()->getArray();
|
||||
|
||||
$model->saveOptions($options);
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode([
|
||||
'htaccess' => $model->generateHtaccess($options),
|
||||
'nginx' => $model->generateNginx($options),
|
||||
]);
|
||||
echo json_encode($result);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Tickets
|
||||
// ==================================================================
|
||||
|
||||
public function createTicket()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.tickets.create'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$this->jsonResponse($this->getModel('Tickets')->createTicket([
|
||||
'subject' => $input->getString('subject', ''),
|
||||
'body' => $input->getRaw('body', ''),
|
||||
'priority' => $input->getString('priority', 'normal'),
|
||||
'category_id' => $input->getInt('category_id', 0),
|
||||
]));
|
||||
}
|
||||
|
||||
public function addTicketReply()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.tickets'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$this->jsonResponse($this->getModel('Tickets')->addReply(
|
||||
$input->getInt('ticket_id', 0),
|
||||
$input->getRaw('body', ''),
|
||||
(bool) $input->getInt('is_internal', 0)
|
||||
));
|
||||
}
|
||||
|
||||
public function updateTicketStatus()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.tickets'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$this->jsonResponse($this->getModel('Tickets')->updateStatus(
|
||||
$input->getInt('ticket_id', 0),
|
||||
$input->getString('status', '')
|
||||
));
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// KB Search
|
||||
// ==================================================================
|
||||
|
||||
public function searchKb()
|
||||
{
|
||||
$query = Factory::getApplication()->getInput()->getString('q', '');
|
||||
|
||||
if (strlen($query) < 3)
|
||||
{
|
||||
$this->jsonResponse(['results' => []]);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
|
||||
|
||||
$results = $db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select([$db->quoteName('l.title'), $db->quoteName('l.url'), $db->quoteName('l.description')])
|
||||
->from($db->quoteName('#__finder_links', 'l'))
|
||||
->where($db->quoteName('l.published') . ' = 1')
|
||||
->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped
|
||||
. ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')')
|
||||
->order($db->quoteName('l.title') . ' ASC')
|
||||
->setLimit(8)
|
||||
)->loadObjectList() ?: [];
|
||||
|
||||
foreach ($results as $r)
|
||||
{
|
||||
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
|
||||
}
|
||||
|
||||
$this->jsonResponse(['results' => $results]);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->jsonResponse(['results' => []]);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Importers
|
||||
// ==================================================================
|
||||
|
||||
public function importAts()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.tickets'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
}
|
||||
|
||||
$this->jsonResponse($this->getModel('Import')->importAts());
|
||||
}
|
||||
|
||||
public function importAdminTools()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
}
|
||||
|
||||
$this->jsonResponse($this->getModel('Import')->importAdminTools());
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Helpers
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Check a MokoWaaS ACL permission for the current user.
|
||||
*/
|
||||
private function checkAcl(string $action): bool
|
||||
{
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
// Super admins always pass
|
||||
if ($user->authorise('core.admin', 'com_mokowaas'))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->authorise($action, 'com_mokowaas');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON response and close.
|
||||
*/
|
||||
private function jsonResponse(array $data): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode($data);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a 403 JSON response and close.
|
||||
*/
|
||||
private function jsonForbidden(): void
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,60 +22,39 @@ class DashboardModel extends BaseDatabaseModel
|
||||
*/
|
||||
private const PLUGIN_META = [
|
||||
'mokowaas' => [
|
||||
'icon' => 'icon-shield-alt',
|
||||
'category' => 'core',
|
||||
'label' => 'Core — Branding & Identity',
|
||||
'description' => 'White-label branding, master user enforcement, emergency access, and plugin protection.',
|
||||
'protected' => true,
|
||||
'configure_only' => false,
|
||||
'icon' => 'icon-shield-alt',
|
||||
'category' => 'core',
|
||||
'label' => 'Core — Branding & Identity',
|
||||
'description' => 'White-label branding, master user enforcement, emergency access, and plugin protection.',
|
||||
'protected' => true,
|
||||
],
|
||||
'mokowaas_firewall' => [
|
||||
'icon' => 'icon-lock',
|
||||
'category' => 'security',
|
||||
'label' => 'Firewall',
|
||||
'description' => 'Web Application Firewall — SQLi, XSS, RFI, DFI shields, IP blocklist, admin secret URL, file protection.',
|
||||
'protected' => false,
|
||||
'configure_only' => false,
|
||||
'icon' => 'icon-lock',
|
||||
'category' => 'security',
|
||||
'label' => 'Firewall',
|
||||
'description' => 'Web Application Firewall — SQLi, XSS, RFI, DFI shields, IP blocklist, admin secret URL, file protection.',
|
||||
'protected' => false,
|
||||
],
|
||||
'mokowaas_tenant' => [
|
||||
'icon' => 'icon-users',
|
||||
'category' => 'security',
|
||||
'label' => 'Tenant Restrictions',
|
||||
'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.',
|
||||
'protected' => false,
|
||||
'configure_only' => false,
|
||||
],
|
||||
'mokowaas_offline' => [
|
||||
'icon' => 'icon-globe',
|
||||
'category' => 'security',
|
||||
'label' => 'Offline Bypass',
|
||||
'description' => 'Keep selected pages (TOS, Privacy Policy) accessible during offline mode.',
|
||||
'protected' => false,
|
||||
'configure_only' => true,
|
||||
'icon' => 'icon-users',
|
||||
'category' => 'security',
|
||||
'label' => 'Tenant Restrictions',
|
||||
'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.',
|
||||
'protected' => false,
|
||||
],
|
||||
'mokowaas_devtools' => [
|
||||
'icon' => 'icon-wrench',
|
||||
'category' => 'tools',
|
||||
'label' => 'Developer Tools',
|
||||
'description' => 'Dev mode, hit counter reset, content version cleanup. Features are controlled inside the plugin settings.',
|
||||
'protected' => false,
|
||||
'configure_only' => true,
|
||||
'icon' => 'icon-wrench',
|
||||
'category' => 'tools',
|
||||
'label' => 'Developer Tools',
|
||||
'description' => 'Dev mode, hit counter reset, content version cleanup.',
|
||||
'protected' => false,
|
||||
],
|
||||
'mokowaasdemo' => [
|
||||
'icon' => 'icon-undo',
|
||||
'category' => 'content',
|
||||
'label' => 'Demo Reset Task',
|
||||
'description' => 'Scheduled demo site reset with content snapshots.',
|
||||
'protected' => false,
|
||||
'configure_only' => true,
|
||||
],
|
||||
'mokowaassync' => [
|
||||
'icon' => 'icon-sync',
|
||||
'category' => 'content',
|
||||
'label' => 'Content Sync Task',
|
||||
'description' => 'Scheduled content synchronisation to remote MokoWaaS sites.',
|
||||
'protected' => false,
|
||||
'configure_only' => true,
|
||||
'mokowaas_monitor' => [
|
||||
'icon' => 'icon-heartbeat',
|
||||
'category' => 'monitoring',
|
||||
'label' => 'Health Monitor',
|
||||
'description' => 'Site health checks, Grafana heartbeat integration, and diagnostics.',
|
||||
'protected' => false,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -118,8 +97,7 @@ class DashboardModel extends BaseDatabaseModel
|
||||
'(' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
|
||||
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('system')
|
||||
. ' AND (' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas')
|
||||
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\_%') . ')'
|
||||
. ' AND ' . $db->quoteName('element') . ' != ' . $db->quote('mokowaas_monitor') . ')'
|
||||
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\_%') . '))'
|
||||
// Webservices plugins
|
||||
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
|
||||
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('webservices')
|
||||
@@ -142,10 +120,8 @@ class DashboardModel extends BaseDatabaseModel
|
||||
$manifest = json_decode($row->manifest_cache ?? '{}');
|
||||
$version = $manifest->version ?? '';
|
||||
|
||||
// Only system plugins and task plugins match PLUGIN_META by element
|
||||
$metaKey = ($row->folder === 'system' || $row->folder === 'task')
|
||||
? $row->element
|
||||
: $row->folder . '_' . $row->element;
|
||||
// Build a lookup key: system plugins use element, others use folder_element
|
||||
$metaKey = $row->element;
|
||||
|
||||
$meta = self::PLUGIN_META[$metaKey] ?? null;
|
||||
|
||||
@@ -159,20 +135,19 @@ class DashboardModel extends BaseDatabaseModel
|
||||
$categoryInfo = self::CATEGORIES[$categoryKey] ?? self::CATEGORIES['tools'];
|
||||
|
||||
$plugins[] = (object) [
|
||||
'extension_id' => (int) $row->extension_id,
|
||||
'name' => $meta['label'] ?? $row->name,
|
||||
'element' => $row->element,
|
||||
'folder' => $row->folder,
|
||||
'type' => $row->type,
|
||||
'enabled' => (int) $row->enabled,
|
||||
'protected' => (bool) ($meta['protected'] ?? false),
|
||||
'configure_only' => (bool) ($meta['configure_only'] ?? false),
|
||||
'version' => $version,
|
||||
'icon' => $meta['icon'] ?? 'icon-puzzle-piece',
|
||||
'category' => $categoryKey,
|
||||
'extension_id' => (int) $row->extension_id,
|
||||
'name' => $meta['label'] ?? $row->name,
|
||||
'element' => $row->element,
|
||||
'folder' => $row->folder,
|
||||
'type' => $row->type,
|
||||
'enabled' => (int) $row->enabled,
|
||||
'protected' => (int) $row->protected || ($meta['protected'] ?? false),
|
||||
'version' => $version,
|
||||
'icon' => $meta['icon'] ?? 'icon-puzzle-piece',
|
||||
'category' => $categoryKey,
|
||||
'categoryLabel' => $categoryInfo['label'],
|
||||
'categoryBadge' => $categoryInfo['badge'],
|
||||
'description' => $meta['description'] ?? '',
|
||||
'description' => $meta['description'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -96,16 +96,6 @@ class ExtensionsModel extends BaseDatabaseModel
|
||||
'article' => 'https://mokoconsulting.tech/kb/mokogallerycalendar',
|
||||
'protected' => false,
|
||||
],
|
||||
'MokoJoomOpenGraph' => [
|
||||
'label' => 'MokoJoomOpenGraph',
|
||||
'description' => 'Open Graph meta tags for articles, categories, and pages. Controls Facebook, Twitter, and LinkedIn link previews.',
|
||||
'element' => 'pkg_mokoog',
|
||||
'type' => 'package',
|
||||
'icon' => 'icon-share-alt',
|
||||
'category' => 'Components',
|
||||
'article' => 'https://mokoconsulting.tech/kb/mokojoomopengraph',
|
||||
'protected' => false,
|
||||
],
|
||||
];
|
||||
|
||||
private const GITEA_URL = 'https://git.mokoconsulting.tech';
|
||||
|
||||
@@ -1,522 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Joomla\Registry\Registry;
|
||||
|
||||
/**
|
||||
* .htaccess / NginX configuration generator.
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
class HtaccessModel extends BaseDatabaseModel
|
||||
{
|
||||
private const DEFAULTS = [
|
||||
// Security
|
||||
'disable_directory_listing' => 1,
|
||||
'block_sensitive_files' => 1,
|
||||
'block_php_in_uploads' => 1,
|
||||
'disable_server_signature' => 1,
|
||||
'prevent_clickjacking' => 1,
|
||||
'prevent_mime_sniffing' => 1,
|
||||
'xss_protection' => 1,
|
||||
'disable_trace_track' => 1,
|
||||
'referrer_policy' => 'strict-origin-when-cross-origin',
|
||||
'hsts_enabled' => 0,
|
||||
'hsts_max_age' => 31536000,
|
||||
'hsts_subdomains' => 0,
|
||||
'csp_enabled' => 0,
|
||||
'csp_value' => '',
|
||||
'permissions_policy' => 0,
|
||||
'permissions_value' => '',
|
||||
// Performance
|
||||
'enable_gzip' => 1,
|
||||
'enable_expires' => 1,
|
||||
'expires_html' => 3600,
|
||||
'expires_css_js' => 2592000,
|
||||
'expires_images' => 31536000,
|
||||
'etag_control' => 0,
|
||||
// SEO
|
||||
'www_redirect' => 'off',
|
||||
'redirect_index_php' => 1,
|
||||
'force_trailing_slash' => 0,
|
||||
// Custom
|
||||
'custom_rules' => '',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get saved options or defaults.
|
||||
*/
|
||||
public function getOptions(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
|
||||
$db->setQuery($query);
|
||||
$params = new Registry($db->loadResult() ?? '{}');
|
||||
|
||||
$htaccess = $params->get('htaccess', null);
|
||||
|
||||
if ($htaccess)
|
||||
{
|
||||
return array_merge(self::DEFAULTS, (array) json_decode(json_encode($htaccess), true));
|
||||
}
|
||||
|
||||
return self::DEFAULTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save options to component params.
|
||||
*/
|
||||
public function saveOptions(array $options): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
|
||||
$db->setQuery($query);
|
||||
$params = new Registry($db->loadResult() ?? '{}');
|
||||
|
||||
$clean = [];
|
||||
|
||||
foreach (self::DEFAULTS as $key => $default)
|
||||
{
|
||||
$clean[$key] = $options[$key] ?? $default;
|
||||
}
|
||||
|
||||
$params->set('htaccess', $clean);
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
)->execute();
|
||||
|
||||
return ['success' => true, 'message' => 'Options saved.'];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Save failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current .htaccess file.
|
||||
*/
|
||||
public function readCurrentHtaccess(): string
|
||||
{
|
||||
$path = JPATH_ROOT . '/.htaccess';
|
||||
|
||||
return file_exists($path) ? file_get_contents($path) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Write .htaccess to disk with backup.
|
||||
*/
|
||||
public function saveHtaccess(string $content): array
|
||||
{
|
||||
$path = JPATH_ROOT . '/.htaccess';
|
||||
$backup = JPATH_ROOT . '/.htaccess.mokowaas.bak';
|
||||
|
||||
try
|
||||
{
|
||||
// Backup existing
|
||||
if (file_exists($path))
|
||||
{
|
||||
copy($path, $backup);
|
||||
}
|
||||
|
||||
$result = file_put_contents($path, $content);
|
||||
|
||||
if ($result === false)
|
||||
{
|
||||
// Restore backup
|
||||
if (file_exists($backup))
|
||||
{
|
||||
copy($backup, $path);
|
||||
}
|
||||
|
||||
return ['success' => false, 'message' => '.htaccess is not writable.'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => '.htaccess saved. Backup at .htaccess.mokowaas.bak'];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
if (file_exists($backup))
|
||||
{
|
||||
@copy($backup, $path);
|
||||
}
|
||||
|
||||
return ['success' => false, 'message' => 'Write failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate .htaccess content from options.
|
||||
*/
|
||||
public function generateHtaccess(array $opts): string
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = '##';
|
||||
$lines[] = '## MokoWaaS Generated .htaccess';
|
||||
$lines[] = '## Generated: ' . gmdate('Y-m-d H:i:s') . ' UTC';
|
||||
$lines[] = '## DO NOT EDIT — regenerate from MokoWaaS > .htaccess Maker';
|
||||
$lines[] = '##';
|
||||
$lines[] = '';
|
||||
|
||||
// --- Security ---
|
||||
if (!empty($opts['disable_directory_listing']))
|
||||
{
|
||||
$lines[] = '## Disable directory listing';
|
||||
$lines[] = 'Options -Indexes';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['disable_server_signature']))
|
||||
{
|
||||
$lines[] = '## Hide server signature';
|
||||
$lines[] = 'ServerSignature Off';
|
||||
$lines[] = '<IfModule mod_headers.c>';
|
||||
$lines[] = ' Header unset X-Powered-By';
|
||||
$lines[] = ' Header unset Server';
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['block_sensitive_files']))
|
||||
{
|
||||
$lines[] = '## Block access to sensitive files';
|
||||
$lines[] = '<FilesMatch "^(htaccess\.txt|web\.config\.txt|configuration\.php-dist|README\.txt|LICENSE\.txt|joomla\.xml|robots\.txt\.dist)$">';
|
||||
$lines[] = ' <IfModule mod_authz_core.c>';
|
||||
$lines[] = ' Require all denied';
|
||||
$lines[] = ' </IfModule>';
|
||||
$lines[] = '</FilesMatch>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['block_php_in_uploads']))
|
||||
{
|
||||
$lines[] = '## Block PHP execution in upload directories';
|
||||
$dirs = ['images', 'media', 'tmp', 'cache', 'logs'];
|
||||
|
||||
foreach ($dirs as $dir)
|
||||
{
|
||||
$lines[] = '<Directory "' . $dir . '">';
|
||||
$lines[] = ' <FilesMatch "\.php$">';
|
||||
$lines[] = ' <IfModule mod_authz_core.c>';
|
||||
$lines[] = ' Require all denied';
|
||||
$lines[] = ' </IfModule>';
|
||||
$lines[] = ' </FilesMatch>';
|
||||
$lines[] = '</Directory>';
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['disable_trace_track']))
|
||||
{
|
||||
$lines[] = '## Disable TRACE and TRACK methods';
|
||||
$lines[] = '<IfModule mod_rewrite.c>';
|
||||
$lines[] = ' RewriteEngine On';
|
||||
$lines[] = ' RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK)';
|
||||
$lines[] = ' RewriteRule .* - [F]';
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
// Security headers
|
||||
$headers = [];
|
||||
|
||||
if (!empty($opts['prevent_clickjacking']))
|
||||
{
|
||||
$headers[] = ' Header always set X-Frame-Options "SAMEORIGIN"';
|
||||
}
|
||||
|
||||
if (!empty($opts['prevent_mime_sniffing']))
|
||||
{
|
||||
$headers[] = ' Header always set X-Content-Type-Options "nosniff"';
|
||||
}
|
||||
|
||||
if (!empty($opts['xss_protection']))
|
||||
{
|
||||
$headers[] = ' Header always set X-XSS-Protection "1; mode=block"';
|
||||
}
|
||||
|
||||
$referrer = $opts['referrer_policy'] ?? '';
|
||||
|
||||
if (!empty($referrer) && $referrer !== 'off')
|
||||
{
|
||||
$headers[] = ' Header always set Referrer-Policy "' . $referrer . '"';
|
||||
}
|
||||
|
||||
if (!empty($opts['hsts_enabled']))
|
||||
{
|
||||
$maxAge = (int) ($opts['hsts_max_age'] ?? 31536000);
|
||||
$hsts = 'max-age=' . $maxAge;
|
||||
|
||||
if (!empty($opts['hsts_subdomains']))
|
||||
{
|
||||
$hsts .= '; includeSubDomains';
|
||||
}
|
||||
|
||||
$headers[] = ' Header always set Strict-Transport-Security "' . $hsts . '"';
|
||||
}
|
||||
|
||||
if (!empty($opts['csp_enabled']) && !empty($opts['csp_value']))
|
||||
{
|
||||
$headers[] = ' Header always set Content-Security-Policy "' . str_replace('"', '', $opts['csp_value']) . '"';
|
||||
}
|
||||
|
||||
if (!empty($opts['permissions_policy']) && !empty($opts['permissions_value']))
|
||||
{
|
||||
$headers[] = ' Header always set Permissions-Policy "' . str_replace('"', '', $opts['permissions_value']) . '"';
|
||||
}
|
||||
|
||||
if (!empty($headers))
|
||||
{
|
||||
$lines[] = '## Security headers';
|
||||
$lines[] = '<IfModule mod_headers.c>';
|
||||
$lines = array_merge($lines, $headers);
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
// --- Performance ---
|
||||
if (!empty($opts['enable_gzip']))
|
||||
{
|
||||
$lines[] = '## GZip compression';
|
||||
$lines[] = '<IfModule mod_deflate.c>';
|
||||
$lines[] = ' AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css';
|
||||
$lines[] = ' AddOutputFilterByType DEFLATE text/javascript application/javascript application/x-javascript';
|
||||
$lines[] = ' AddOutputFilterByType DEFLATE application/json application/xml application/rss+xml';
|
||||
$lines[] = ' AddOutputFilterByType DEFLATE image/svg+xml application/font-woff application/font-woff2';
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['enable_expires']))
|
||||
{
|
||||
$html = (int) ($opts['expires_html'] ?? 3600);
|
||||
$cssJs = (int) ($opts['expires_css_js'] ?? 2592000);
|
||||
$images = (int) ($opts['expires_images'] ?? 31536000);
|
||||
|
||||
$lines[] = '## Browser caching';
|
||||
$lines[] = '<IfModule mod_expires.c>';
|
||||
$lines[] = ' ExpiresActive On';
|
||||
$lines[] = ' ExpiresDefault "access plus ' . $html . ' seconds"';
|
||||
$lines[] = ' ExpiresByType text/html "access plus ' . $html . ' seconds"';
|
||||
$lines[] = ' ExpiresByType text/css "access plus ' . $cssJs . ' seconds"';
|
||||
$lines[] = ' ExpiresByType text/javascript "access plus ' . $cssJs . ' seconds"';
|
||||
$lines[] = ' ExpiresByType application/javascript "access plus ' . $cssJs . ' seconds"';
|
||||
$lines[] = ' ExpiresByType image/jpeg "access plus ' . $images . ' seconds"';
|
||||
$lines[] = ' ExpiresByType image/png "access plus ' . $images . ' seconds"';
|
||||
$lines[] = ' ExpiresByType image/gif "access plus ' . $images . ' seconds"';
|
||||
$lines[] = ' ExpiresByType image/webp "access plus ' . $images . ' seconds"';
|
||||
$lines[] = ' ExpiresByType image/svg+xml "access plus ' . $images . ' seconds"';
|
||||
$lines[] = ' ExpiresByType font/woff2 "access plus ' . $images . ' seconds"';
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['etag_control']))
|
||||
{
|
||||
$lines[] = '## Disable ETags (for load-balanced environments)';
|
||||
$lines[] = '<IfModule mod_headers.c>';
|
||||
$lines[] = ' Header unset ETag';
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = 'FileETag None';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
// --- SEO / Redirects ---
|
||||
$wwwRedirect = $opts['www_redirect'] ?? 'off';
|
||||
|
||||
if ($wwwRedirect !== 'off' || !empty($opts['redirect_index_php']) || !empty($opts['force_trailing_slash']))
|
||||
{
|
||||
$lines[] = '## SEO redirects';
|
||||
$lines[] = '<IfModule mod_rewrite.c>';
|
||||
$lines[] = ' RewriteEngine On';
|
||||
|
||||
if ($wwwRedirect === 'www')
|
||||
{
|
||||
$lines[] = '';
|
||||
$lines[] = ' ## Force www';
|
||||
$lines[] = ' RewriteCond %{HTTP_HOST} !^www\. [NC]';
|
||||
$lines[] = ' RewriteRule ^(.*)$ https://www.%{HTTP_HOST}/$1 [R=301,L]';
|
||||
}
|
||||
elseif ($wwwRedirect === 'non-www')
|
||||
{
|
||||
$lines[] = '';
|
||||
$lines[] = ' ## Force non-www';
|
||||
$lines[] = ' RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]';
|
||||
$lines[] = ' RewriteRule ^(.*)$ https://%1/$1 [R=301,L]';
|
||||
}
|
||||
|
||||
if (!empty($opts['redirect_index_php']))
|
||||
{
|
||||
$lines[] = '';
|
||||
$lines[] = ' ## Redirect /index.php to root';
|
||||
$lines[] = ' RewriteCond %{THE_REQUEST} ^[A-Z]{3,}\s/+index\.php\s [NC]';
|
||||
$lines[] = ' RewriteRule ^index\.php/?(.*)$ /$1 [R=301,L]';
|
||||
}
|
||||
|
||||
if (!empty($opts['force_trailing_slash']))
|
||||
{
|
||||
$lines[] = '';
|
||||
$lines[] = ' ## Force trailing slash';
|
||||
$lines[] = ' RewriteCond %{REQUEST_FILENAME} !-f';
|
||||
$lines[] = ' RewriteCond %{REQUEST_URI} !(.*)/$';
|
||||
$lines[] = ' RewriteRule ^(.*)$ /$1/ [R=301,L]';
|
||||
}
|
||||
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
// --- Custom rules ---
|
||||
$custom = trim($opts['custom_rules'] ?? '');
|
||||
|
||||
if (!empty($custom))
|
||||
{
|
||||
$lines[] = '## Custom rules';
|
||||
$lines[] = $custom;
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate equivalent NginX configuration snippet.
|
||||
*/
|
||||
public function generateNginx(array $opts): string
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = '## MokoWaaS Generated NginX Configuration';
|
||||
$lines[] = '## Add these directives inside your server { } block';
|
||||
$lines[] = '';
|
||||
|
||||
if (!empty($opts['disable_directory_listing']))
|
||||
{
|
||||
$lines[] = '# Disable directory listing';
|
||||
$lines[] = 'autoindex off;';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['disable_server_signature']))
|
||||
{
|
||||
$lines[] = '# Hide server version';
|
||||
$lines[] = 'server_tokens off;';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['block_sensitive_files']))
|
||||
{
|
||||
$lines[] = '# Block sensitive files';
|
||||
$lines[] = 'location ~* (htaccess\.txt|web\.config\.txt|configuration\.php-dist|README\.txt|LICENSE\.txt)$ {';
|
||||
$lines[] = ' deny all;';
|
||||
$lines[] = '}';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['block_php_in_uploads']))
|
||||
{
|
||||
$lines[] = '# Block PHP in upload directories';
|
||||
$lines[] = 'location ~* ^/(images|media|tmp|cache|logs)/.*\.php$ {';
|
||||
$lines[] = ' deny all;';
|
||||
$lines[] = '}';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
// Headers
|
||||
$hdrs = [];
|
||||
|
||||
if (!empty($opts['prevent_clickjacking']))
|
||||
{
|
||||
$hdrs[] = 'add_header X-Frame-Options "SAMEORIGIN" always;';
|
||||
}
|
||||
|
||||
if (!empty($opts['prevent_mime_sniffing']))
|
||||
{
|
||||
$hdrs[] = 'add_header X-Content-Type-Options "nosniff" always;';
|
||||
}
|
||||
|
||||
if (!empty($opts['xss_protection']))
|
||||
{
|
||||
$hdrs[] = 'add_header X-XSS-Protection "1; mode=block" always;';
|
||||
}
|
||||
|
||||
$referrer = $opts['referrer_policy'] ?? '';
|
||||
|
||||
if (!empty($referrer) && $referrer !== 'off')
|
||||
{
|
||||
$hdrs[] = 'add_header Referrer-Policy "' . $referrer . '" always;';
|
||||
}
|
||||
|
||||
if (!empty($opts['hsts_enabled']))
|
||||
{
|
||||
$maxAge = (int) ($opts['hsts_max_age'] ?? 31536000);
|
||||
$hsts = 'max-age=' . $maxAge;
|
||||
|
||||
if (!empty($opts['hsts_subdomains']))
|
||||
{
|
||||
$hsts .= '; includeSubDomains';
|
||||
}
|
||||
|
||||
$hdrs[] = 'add_header Strict-Transport-Security "' . $hsts . '" always;';
|
||||
}
|
||||
|
||||
if (!empty($hdrs))
|
||||
{
|
||||
$lines[] = '# Security headers';
|
||||
$lines = array_merge($lines, $hdrs);
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['enable_gzip']))
|
||||
{
|
||||
$lines[] = '# GZip compression';
|
||||
$lines[] = 'gzip on;';
|
||||
$lines[] = 'gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;';
|
||||
$lines[] = 'gzip_min_length 256;';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['enable_expires']))
|
||||
{
|
||||
$cssJs = (int) ($opts['expires_css_js'] ?? 2592000);
|
||||
$images = (int) ($opts['expires_images'] ?? 31536000);
|
||||
|
||||
$lines[] = '# Browser caching';
|
||||
$lines[] = 'location ~* \.(css|js)$ {';
|
||||
$lines[] = ' expires ' . round($cssJs / 86400) . 'd;';
|
||||
$lines[] = '}';
|
||||
$lines[] = 'location ~* \.(jpg|jpeg|png|gif|webp|svg|ico|woff2)$ {';
|
||||
$lines[] = ' expires ' . round($images / 86400) . 'd;';
|
||||
$lines[] = '}';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
}
|
||||
@@ -1,617 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Joomla\Registry\Registry;
|
||||
|
||||
/**
|
||||
* Importer for migrating from Akeeba Admin Tools to MokoWaaS.
|
||||
*
|
||||
* Reads Admin Tools WAF config, htaccess settings, IP blocklists,
|
||||
* and security headers — maps them to MokoWaaS firewall plugin params
|
||||
* and htaccess maker options.
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
class ImportModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Check if Admin Tools data is available for import.
|
||||
*/
|
||||
public function checkAdminToolsAvailable(): ?object
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
try
|
||||
{
|
||||
$result = (object) [
|
||||
'component' => false,
|
||||
'waf_config' => false,
|
||||
'storage' => false,
|
||||
'ip_blocks' => 0,
|
||||
];
|
||||
|
||||
// Check component
|
||||
$db->setQuery("SELECT COUNT(*) FROM #__extensions WHERE element = 'com_admintools' AND type = 'component'");
|
||||
$result->component = (int) $db->loadResult() > 0;
|
||||
|
||||
// Check WAF config table
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_wafconfig%'));
|
||||
|
||||
if ($db->loadResult())
|
||||
{
|
||||
$result->waf_config = true;
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__admintools_wafconfig');
|
||||
$result->waf_settings = (int) $db->loadResult();
|
||||
}
|
||||
|
||||
// Check storage table
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_storage%'));
|
||||
|
||||
if ($db->loadResult())
|
||||
{
|
||||
$result->storage = true;
|
||||
}
|
||||
|
||||
// Check IP blocklist
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_ipblock%'));
|
||||
|
||||
if ($db->loadResult())
|
||||
{
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__admintools_ipblock');
|
||||
$result->ip_blocks = (int) $db->loadResult();
|
||||
}
|
||||
|
||||
// Only available if at least one data source exists
|
||||
if (!$result->component && !$result->waf_config && !$result->storage)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import Admin Tools settings into MokoWaaS.
|
||||
*/
|
||||
public function importAdminTools(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$results = ['firewall' => 0, 'htaccess' => 0, 'ip_blocks' => 0, 'disabled' => false];
|
||||
|
||||
try
|
||||
{
|
||||
// ============================================================
|
||||
// 1. Import WAF Config → Firewall plugin params
|
||||
// ============================================================
|
||||
$wafSettings = $this->readWafConfig($db);
|
||||
$firewallParams = $this->mapWafToFirewall($wafSettings);
|
||||
|
||||
if (!empty($firewallParams))
|
||||
{
|
||||
$this->mergePluginParams('mokowaas_firewall', 'system', $firewallParams);
|
||||
$results['firewall'] = \count($firewallParams);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. Import htaccess settings → component htaccess options
|
||||
// ============================================================
|
||||
$htaccessSettings = $this->readHtaccessConfig($db);
|
||||
$htaccessOptions = $this->mapToHtaccess($htaccessSettings, $wafSettings);
|
||||
|
||||
if (!empty($htaccessOptions))
|
||||
{
|
||||
$this->mergeComponentHtaccessOptions($htaccessOptions);
|
||||
$results['htaccess'] = \count($htaccessOptions);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. Import IP blocklist → Firewall IP deny list
|
||||
// ============================================================
|
||||
$ipBlocks = $this->readIpBlocklist($db);
|
||||
|
||||
if (!empty($ipBlocks))
|
||||
{
|
||||
$this->mergeIpBlocklist($ipBlocks);
|
||||
$results['ip_blocks'] = \count($ipBlocks);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 4. Disable Admin Tools
|
||||
// ============================================================
|
||||
$this->disableAdminTools($db);
|
||||
$results['disabled'] = true;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => \sprintf(
|
||||
'Imported %d firewall settings, %d htaccess options, %d blocked IPs from Admin Tools. Admin Tools has been disabled.',
|
||||
$results['firewall'], $results['htaccess'], $results['ip_blocks']
|
||||
),
|
||||
'counts' => $results,
|
||||
];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read WAF config from #__admintools_wafconfig.
|
||||
*/
|
||||
private function readWafConfig($db): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_wafconfig%'));
|
||||
|
||||
if (!$db->loadResult())
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$db->setQuery('SELECT * FROM #__admintools_wafconfig');
|
||||
$rows = $db->loadObjectList() ?: [];
|
||||
|
||||
$config = [];
|
||||
|
||||
foreach ($rows as $row)
|
||||
{
|
||||
$key = $row->key ?? $row->option ?? '';
|
||||
|
||||
if (!empty($key))
|
||||
{
|
||||
$config[$key] = $row->value ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read htaccess/server config from #__admintools_storage.
|
||||
*/
|
||||
private function readHtaccessConfig($db): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_storage%'));
|
||||
|
||||
if (!$db->loadResult())
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$db->setQuery('SELECT * FROM #__admintools_storage');
|
||||
$rows = $db->loadObjectList() ?: [];
|
||||
|
||||
$config = [];
|
||||
|
||||
foreach ($rows as $row)
|
||||
{
|
||||
$key = $row->key ?? '';
|
||||
|
||||
if (!empty($key))
|
||||
{
|
||||
$config[$key] = $row->value ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read IP blocklist from #__admintools_ipblock.
|
||||
*/
|
||||
private function readIpBlocklist($db): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_ipblock%'));
|
||||
|
||||
if (!$db->loadResult())
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$db->setQuery('SELECT ip FROM #__admintools_ipblock');
|
||||
|
||||
return $db->loadColumn() ?: [];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Admin Tools WAF config to MokoWaaS firewall plugin params.
|
||||
*/
|
||||
private function mapWafToFirewall(array $waf): array
|
||||
{
|
||||
$params = [];
|
||||
|
||||
// WAF shields
|
||||
if (isset($waf['sqlishield']))
|
||||
{
|
||||
$params['waf_sqli'] = (int) $waf['sqlishield'] ? 1 : 0;
|
||||
}
|
||||
|
||||
if (isset($waf['antispam']))
|
||||
{
|
||||
$params['waf_xss'] = (int) $waf['antispam'] ? 1 : 0;
|
||||
}
|
||||
|
||||
if (isset($waf['muashield']))
|
||||
{
|
||||
$params['waf_mua'] = (int) $waf['muashield'] ? 1 : 0;
|
||||
}
|
||||
|
||||
if (isset($waf['rfishield']))
|
||||
{
|
||||
$params['waf_rfi'] = (int) $waf['rfishield'] ? 1 : 0;
|
||||
}
|
||||
|
||||
if (isset($waf['dfishield']))
|
||||
{
|
||||
$params['waf_dfi'] = (int) $waf['dfishield'] ? 1 : 0;
|
||||
}
|
||||
|
||||
if (isset($waf['uploadshield']))
|
||||
{
|
||||
// Map to our block_direct_php
|
||||
$params['block_direct_php'] = (int) $waf['uploadshield'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Admin secret URL
|
||||
if (!empty($waf['adminpw']))
|
||||
{
|
||||
$params['admin_secret'] = $waf['adminpw'];
|
||||
}
|
||||
|
||||
// Block frontend super user login
|
||||
if (isset($waf['nofesalogin']))
|
||||
{
|
||||
$params['block_frontend_superuser'] = (int) $waf['nofesalogin'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Session timeout
|
||||
if (!empty($waf['sessionshield']) && !empty($waf['session_timeout']))
|
||||
{
|
||||
$params['admin_session_timeout'] = (int) $waf['session_timeout'];
|
||||
}
|
||||
|
||||
// Template switch blocking
|
||||
if (isset($waf['tmpl']))
|
||||
{
|
||||
$params['block_template_switch'] = (int) $waf['tmpl'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Blocked sensitive files
|
||||
if (isset($waf['hogfiles']))
|
||||
{
|
||||
$params['block_sensitive_files'] = (int) $waf['hogfiles'] ? 1 : 0;
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Admin Tools config to MokoWaaS htaccess maker options.
|
||||
*/
|
||||
private function mapToHtaccess(array $storage, array $waf): array
|
||||
{
|
||||
$opts = [];
|
||||
|
||||
// Server signature
|
||||
if (isset($waf['serversignature']) || isset($storage['serversignature']))
|
||||
{
|
||||
$opts['disable_server_signature'] = 1;
|
||||
}
|
||||
|
||||
// Clickjacking
|
||||
if (isset($waf['clickjacking']) || isset($storage['xframeoptions']))
|
||||
{
|
||||
$opts['prevent_clickjacking'] = 1;
|
||||
}
|
||||
|
||||
// HSTS
|
||||
if (!empty($storage['hstsheader']) || !empty($waf['hstsheader']))
|
||||
{
|
||||
$opts['hsts_enabled'] = 1;
|
||||
|
||||
if (!empty($storage['hstsmaxage']))
|
||||
{
|
||||
$opts['hsts_max_age'] = (int) $storage['hstsmaxage'];
|
||||
}
|
||||
}
|
||||
|
||||
// GZip
|
||||
if (isset($storage['gzipcompression']))
|
||||
{
|
||||
$opts['enable_gzip'] = (int) $storage['gzipcompression'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Expiration
|
||||
if (isset($storage['exptime']))
|
||||
{
|
||||
$opts['enable_expires'] = (int) $storage['exptime'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// ETag
|
||||
if (isset($storage['etagtype']))
|
||||
{
|
||||
$opts['etag_control'] = ($storage['etagtype'] === 'none') ? 1 : 0;
|
||||
}
|
||||
|
||||
// Redirect www / non-www
|
||||
if (!empty($storage['wwwredir']))
|
||||
{
|
||||
$map = ['www' => 'www', 'nowww' => 'non-www'];
|
||||
$opts['www_redirect'] = $map[$storage['wwwredir']] ?? 'off';
|
||||
}
|
||||
|
||||
// Directory listing
|
||||
if (isset($storage['nodirlisting']))
|
||||
{
|
||||
$opts['disable_directory_listing'] = (int) $storage['nodirlisting'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Block PHP in uploads
|
||||
if (isset($storage['phpuploadexec']))
|
||||
{
|
||||
$opts['block_php_in_uploads'] = (int) $storage['phpuploadexec'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Sensitive files
|
||||
if (isset($storage['hogfiles']))
|
||||
{
|
||||
$opts['block_sensitive_files'] = (int) $storage['hogfiles'] ? 1 : 0;
|
||||
}
|
||||
|
||||
return $opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge params into a plugin's existing params.
|
||||
*/
|
||||
private function mergePluginParams(string $element, string $folder, array $newParams): void
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($element))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote($folder));
|
||||
$db->setQuery($query);
|
||||
$current = new Registry($db->loadResult() ?? '{}');
|
||||
|
||||
foreach ($newParams as $key => $value)
|
||||
{
|
||||
$current->set($key, $value);
|
||||
}
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($current->toString()))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($element))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote($folder))
|
||||
)->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge htaccess options into the component params.
|
||||
*/
|
||||
private function mergeComponentHtaccessOptions(array $options): void
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
|
||||
$db->setQuery($query);
|
||||
$params = new Registry($db->loadResult() ?? '{}');
|
||||
|
||||
$htaccess = (array) json_decode(json_encode($params->get('htaccess', new \stdClass())), true);
|
||||
|
||||
foreach ($options as $key => $value)
|
||||
{
|
||||
$htaccess[$key] = $value;
|
||||
}
|
||||
|
||||
$params->set('htaccess', $htaccess);
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
)->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge imported IPs into the firewall IP blocklist.
|
||||
*/
|
||||
private function mergeIpBlocklist(array $ips): void
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
|
||||
$db->setQuery($query);
|
||||
$params = new Registry($db->loadResult() ?? '{}');
|
||||
|
||||
$blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: [];
|
||||
|
||||
$existingIps = array_column($blocklist, 'ip');
|
||||
|
||||
foreach ($ips as $ip)
|
||||
{
|
||||
$ip = trim($ip);
|
||||
|
||||
if (empty($ip) || \in_array($ip, $existingIps, true))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$blocklist[] = [
|
||||
'ip' => $ip,
|
||||
'enabled' => '1',
|
||||
'label' => 'Imported from Admin Tools',
|
||||
];
|
||||
}
|
||||
|
||||
$params->set('ip_blocklist', json_encode($blocklist));
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
)->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable Admin Tools component and plugins.
|
||||
*/
|
||||
private function disableAdminTools($db): void
|
||||
{
|
||||
// Disable component
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 0')
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_admintools'))
|
||||
)->execute();
|
||||
|
||||
// Disable all Admin Tools plugins
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 0')
|
||||
->where($db->quoteName('element') . ' LIKE ' . $db->quote('admintools%'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
)->execute();
|
||||
|
||||
Log::add('Admin Tools component and plugins disabled after MokoWaaS import', Log::INFO, 'mokowaas');
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Akeeba Ticket System Import
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Check if ATS tables exist.
|
||||
*/
|
||||
public function checkAtsAvailable(): ?object
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
try
|
||||
{
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%ats_tickets%'));
|
||||
|
||||
if (!$db->loadResult())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__ats_tickets');
|
||||
$tickets = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__ats_posts');
|
||||
$posts = (int) $db->loadResult();
|
||||
|
||||
return (object) ['tickets' => $tickets, 'posts' => $posts];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import from Akeeba Ticket System and disable it.
|
||||
*/
|
||||
public function importAts(): array
|
||||
{
|
||||
// Delegate to TicketsModel for the actual import
|
||||
$ticketsModel = new TicketsModel();
|
||||
$result = $ticketsModel->importFromAts();
|
||||
|
||||
if (!$result['success'])
|
||||
{
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Disable ATS after successful import
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 0')
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_ats'))
|
||||
)->execute();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 0')
|
||||
->where($db->quoteName('element') . ' LIKE ' . $db->quote('ats%'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
)->execute();
|
||||
|
||||
$result['message'] .= ' Akeeba Ticket System has been disabled.';
|
||||
Log::add('Akeeba Ticket System disabled after MokoWaaS import', Log::INFO, 'mokowaas');
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$result['message'] .= ' Warning: could not disable ATS: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -1,793 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class TicketsModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get ticket list with filters.
|
||||
*/
|
||||
public function getTickets(array $filters = []): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('t.id'),
|
||||
$db->quoteName('t.subject'),
|
||||
$db->quoteName('t.status'),
|
||||
$db->quoteName('t.priority'),
|
||||
$db->quoteName('t.created'),
|
||||
$db->quoteName('t.modified'),
|
||||
$db->quoteName('t.sla_response_due'),
|
||||
$db->quoteName('t.sla_resolution_due'),
|
||||
$db->quoteName('t.sla_responded'),
|
||||
$db->quoteName('c.title', 'category_title'),
|
||||
$db->quoteName('u.name', 'created_by_name'),
|
||||
$db->quoteName('a.name', 'assigned_to_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokowaas_tickets', 't'))
|
||||
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to');
|
||||
|
||||
if (!empty($filters['status']))
|
||||
{
|
||||
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($filters['status']));
|
||||
}
|
||||
|
||||
if (!empty($filters['priority']))
|
||||
{
|
||||
$query->where($db->quoteName('t.priority') . ' = ' . $db->quote($filters['priority']));
|
||||
}
|
||||
|
||||
if (!empty($filters['assigned_to']))
|
||||
{
|
||||
$query->where($db->quoteName('t.assigned_to') . ' = ' . (int) $filters['assigned_to']);
|
||||
}
|
||||
|
||||
if (!empty($filters['category_id']))
|
||||
{
|
||||
$query->where($db->quoteName('t.category_id') . ' = ' . (int) $filters['category_id']);
|
||||
}
|
||||
|
||||
$query->order($db->quoteName('t.created') . ' DESC');
|
||||
$query->setLimit(50);
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single ticket with all replies.
|
||||
*/
|
||||
public function getTicket(int $id): ?object
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('t') . '.*',
|
||||
$db->quoteName('c.title', 'category_title'),
|
||||
$db->quoteName('u.name', 'created_by_name'),
|
||||
$db->quoteName('u.email', 'created_by_email'),
|
||||
$db->quoteName('a.name', 'assigned_to_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokowaas_tickets', 't'))
|
||||
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to')
|
||||
->where($db->quoteName('t.id') . ' = ' . $id);
|
||||
$db->setQuery($query);
|
||||
$ticket = $db->loadObject();
|
||||
|
||||
if (!$ticket)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load replies
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('r') . '.*',
|
||||
$db->quoteName('u.name', 'user_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokowaas_ticket_replies', 'r'))
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
|
||||
->where($db->quoteName('r.ticket_id') . ' = ' . $id)
|
||||
->order($db->quoteName('r.created') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$ticket->replies = $db->loadObjectList() ?: [];
|
||||
|
||||
// Reply count
|
||||
$ticket->reply_count = \count($ticket->replies);
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new ticket.
|
||||
*/
|
||||
public function createTicket(array $data): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
$ticket = (object) [
|
||||
'subject' => $data['subject'] ?? '',
|
||||
'body' => $data['body'] ?? '',
|
||||
'status' => 'open',
|
||||
'priority' => $data['priority'] ?? 'normal',
|
||||
'category_id' => (int) ($data['category_id'] ?? 0) ?: null,
|
||||
'created_by' => $user->id,
|
||||
'assigned_to' => (int) ($data['assigned_to'] ?? 0) ?: null,
|
||||
'created' => $now,
|
||||
'modified' => $now,
|
||||
];
|
||||
|
||||
// Auto-assign from category
|
||||
if (!$ticket->assigned_to && $ticket->category_id)
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('auto_assign_user'))
|
||||
->from($db->quoteName('#__mokowaas_ticket_categories'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id);
|
||||
$db->setQuery($query);
|
||||
$autoAssign = (int) $db->loadResult();
|
||||
|
||||
if ($autoAssign)
|
||||
{
|
||||
$ticket->assigned_to = $autoAssign;
|
||||
}
|
||||
}
|
||||
|
||||
// SLA deadlines from category
|
||||
if ($ticket->category_id)
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('sla_response_minutes'), $db->quoteName('sla_resolution_minutes')])
|
||||
->from($db->quoteName('#__mokowaas_ticket_categories'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id);
|
||||
$db->setQuery($query);
|
||||
$sla = $db->loadObject();
|
||||
|
||||
if ($sla)
|
||||
{
|
||||
$ticket->sla_response_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_response_minutes . ' minutes')->toSql();
|
||||
$ticket->sla_resolution_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_resolution_minutes . ' minutes')->toSql();
|
||||
}
|
||||
}
|
||||
|
||||
$db->insertObject('#__mokowaas_tickets', $ticket, 'id');
|
||||
|
||||
// Run automation
|
||||
$this->runAutomation('ticket_created', (int) $ticket->id);
|
||||
|
||||
return ['success' => true, 'message' => 'Ticket #' . $ticket->id . ' created.', 'id' => (int) $ticket->id];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a reply to a ticket.
|
||||
*/
|
||||
public function addReply(int $ticketId, string $body, bool $isInternal = false): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
$reply = (object) [
|
||||
'ticket_id' => $ticketId,
|
||||
'user_id' => $user->id,
|
||||
'body' => $body,
|
||||
'is_internal' => $isInternal ? 1 : 0,
|
||||
'created' => $now,
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokowaas_ticket_replies', $reply, 'id');
|
||||
|
||||
// Mark SLA as responded if first staff reply
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_tickets'))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
|
||||
->set($db->quoteName('sla_responded') . ' = 1')
|
||||
->where($db->quoteName('id') . ' = ' . $ticketId)
|
||||
->where($db->quoteName('sla_responded') . ' = 0')
|
||||
)->execute();
|
||||
|
||||
// Run automation
|
||||
$this->runAutomation('ticket_replied', $ticketId);
|
||||
|
||||
return ['success' => true, 'message' => 'Reply added.'];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update ticket status.
|
||||
*/
|
||||
public function updateStatus(int $ticketId, string $status): array
|
||||
{
|
||||
$valid = ['open', 'in_progress', 'waiting', 'resolved', 'closed'];
|
||||
|
||||
if (!\in_array($status, $valid, true))
|
||||
{
|
||||
return ['success' => false, 'message' => 'Invalid status.'];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
$sets = [
|
||||
$db->quoteName('status') . ' = ' . $db->quote($status),
|
||||
$db->quoteName('modified') . ' = ' . $db->quote($now),
|
||||
];
|
||||
|
||||
if ($status === 'resolved')
|
||||
{
|
||||
$sets[] = $db->quoteName('resolved') . ' = ' . $db->quote($now);
|
||||
}
|
||||
|
||||
if ($status === 'closed')
|
||||
{
|
||||
$sets[] = $db->quoteName('closed') . ' = ' . $db->quote($now);
|
||||
}
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_tickets'))
|
||||
->set($sets)
|
||||
->where($db->quoteName('id') . ' = ' . $ticketId)
|
||||
)->execute();
|
||||
|
||||
// Run automation
|
||||
$this->runAutomation('status_changed', $ticketId);
|
||||
|
||||
return ['success' => true, 'message' => 'Status updated to ' . $status . '.'];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ticket categories.
|
||||
*/
|
||||
public function getCategories(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_ticket_categories'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC')
|
||||
);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get canned responses, optionally filtered by category.
|
||||
*/
|
||||
public function getCannedResponses(int $categoryId = 0): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_ticket_canned'))
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
|
||||
if ($categoryId)
|
||||
{
|
||||
$query->where('(' . $db->quoteName('category_id') . ' = ' . $categoryId
|
||||
. ' OR ' . $db->quoteName('category_id') . ' IS NULL)');
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ticket counts by status for dashboard.
|
||||
*/
|
||||
public function getStatusCounts(): object
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select([$db->quoteName('status'), 'COUNT(*) AS ' . $db->quoteName('cnt')])
|
||||
->from($db->quoteName('#__mokowaas_tickets'))
|
||||
->group($db->quoteName('status'))
|
||||
);
|
||||
$rows = $db->loadObjectList('status') ?: [];
|
||||
|
||||
return (object) [
|
||||
'open' => (int) ($rows['open']->cnt ?? 0),
|
||||
'in_progress' => (int) ($rows['in_progress']->cnt ?? 0),
|
||||
'waiting' => (int) ($rows['waiting']->cnt ?? 0),
|
||||
'resolved' => (int) ($rows['resolved']->cnt ?? 0),
|
||||
'closed' => (int) ($rows['closed']->cnt ?? 0),
|
||||
'total' => array_sum(array_map(fn($r) => (int) $r->cnt, $rows)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overdue tickets (SLA breached).
|
||||
*/
|
||||
public function getOverdueTickets(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('id'), $db->quoteName('subject'), $db->quoteName('priority'),
|
||||
$db->quoteName('sla_response_due'), $db->quoteName('sla_resolution_due'), $db->quoteName('sla_responded')])
|
||||
->from($db->quoteName('#__mokowaas_tickets'))
|
||||
->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')')
|
||||
->where('((' . $db->quoteName('sla_response_due') . ' < ' . $db->quote($now) . ' AND ' . $db->quoteName('sla_responded') . ' = 0)'
|
||||
. ' OR ' . $db->quoteName('sla_resolution_due') . ' < ' . $db->quote($now) . ')')
|
||||
->order($db->quoteName('sla_resolution_due') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Automation Engine
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Run automation rules for a specific trigger event against a ticket.
|
||||
*
|
||||
* @param string $event trigger_event: ticket_created, ticket_replied, status_changed, scheduled
|
||||
* @param int $ticketId The ticket to evaluate
|
||||
*/
|
||||
public function runAutomation(string $event, int $ticketId): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
// Load enabled rules for this event
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_ticket_automation'))
|
||||
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
|
||||
->where($db->quoteName('enabled') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$rules = $db->loadObjectList() ?: [];
|
||||
|
||||
if (empty($rules))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the ticket
|
||||
$ticket = $this->getTicket($ticketId);
|
||||
|
||||
if (!$ticket)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate age in hours
|
||||
$ticket->age_hours = (time() - strtotime($ticket->created)) / 3600;
|
||||
|
||||
foreach ($rules as $rule)
|
||||
{
|
||||
$conditions = json_decode($rule->conditions, true) ?: [];
|
||||
$actions = json_decode($rule->actions, true) ?: [];
|
||||
|
||||
if ($this->evaluateConditions($conditions, $ticket))
|
||||
{
|
||||
$this->executeActions($actions, $ticketId, $ticket);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
\Joomla\CMS\Log\Log::add('Automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all scheduled automation rules against all open tickets.
|
||||
*/
|
||||
public function runScheduledAutomation(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$results = ['evaluated' => 0, 'acted' => 0];
|
||||
|
||||
// Load scheduled rules
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_ticket_automation'))
|
||||
->where($db->quoteName('trigger_event') . ' = ' . $db->quote('scheduled'))
|
||||
->where($db->quoteName('enabled') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$rules = $db->loadObjectList() ?: [];
|
||||
|
||||
if (empty($rules))
|
||||
{
|
||||
return $results;
|
||||
}
|
||||
|
||||
// Load all non-closed tickets
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_tickets'))
|
||||
->where($db->quoteName('status') . ' != ' . $db->quote('closed'));
|
||||
$db->setQuery($query);
|
||||
$tickets = $db->loadObjectList() ?: [];
|
||||
|
||||
foreach ($tickets as $ticket)
|
||||
{
|
||||
$ticket->age_hours = (time() - strtotime($ticket->created)) / 3600;
|
||||
$ticket->replies = [];
|
||||
$results['evaluated']++;
|
||||
|
||||
foreach ($rules as $rule)
|
||||
{
|
||||
$conditions = json_decode($rule->conditions, true) ?: [];
|
||||
$actions = json_decode($rule->actions, true) ?: [];
|
||||
|
||||
if ($this->evaluateConditions($conditions, $ticket))
|
||||
{
|
||||
$this->executeActions($actions, (int) $ticket->id, $ticket);
|
||||
$results['acted']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a set of conditions against a ticket (all must match).
|
||||
*/
|
||||
private function evaluateConditions(array $conditions, object $ticket): bool
|
||||
{
|
||||
foreach ($conditions as $cond)
|
||||
{
|
||||
$field = $cond['field'] ?? '';
|
||||
$op = $cond['op'] ?? 'eq';
|
||||
$value = $cond['value'] ?? '';
|
||||
|
||||
$ticketValue = $ticket->{$field} ?? null;
|
||||
|
||||
if ($ticketValue === null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch ($op)
|
||||
{
|
||||
case 'eq':
|
||||
if ((string) $ticketValue !== (string) $value) return false;
|
||||
break;
|
||||
case 'neq':
|
||||
if ((string) $ticketValue === (string) $value) return false;
|
||||
break;
|
||||
case 'gt':
|
||||
if ((float) $ticketValue <= (float) $value) return false;
|
||||
break;
|
||||
case 'lt':
|
||||
if ((float) $ticketValue >= (float) $value) return false;
|
||||
break;
|
||||
case 'in':
|
||||
$list = array_map('trim', explode(',', $value));
|
||||
if (!\in_array((string) $ticketValue, $list, true)) return false;
|
||||
break;
|
||||
case 'not_in':
|
||||
$list = array_map('trim', explode(',', $value));
|
||||
if (\in_array((string) $ticketValue, $list, true)) return false;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a set of actions on a ticket.
|
||||
*/
|
||||
private function executeActions(array $actions, int $ticketId, object $ticket): void
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
foreach ($actions as $action)
|
||||
{
|
||||
$type = $action['type'] ?? '';
|
||||
$value = $action['value'] ?? '';
|
||||
|
||||
switch ($type)
|
||||
{
|
||||
case 'set_status':
|
||||
$this->updateStatus($ticketId, $value);
|
||||
break;
|
||||
|
||||
case 'set_priority':
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_tickets'))
|
||||
->set($db->quoteName('priority') . ' = ' . $db->quote($value))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
|
||||
->where($db->quoteName('id') . ' = ' . $ticketId)
|
||||
)->execute();
|
||||
break;
|
||||
|
||||
case 'assign':
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_tickets'))
|
||||
->set($db->quoteName('assigned_to') . ' = ' . (int) $value)
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
|
||||
->where($db->quoteName('id') . ' = ' . $ticketId)
|
||||
)->execute();
|
||||
break;
|
||||
|
||||
case 'add_note':
|
||||
$reply = (object) [
|
||||
'ticket_id' => $ticketId,
|
||||
'user_id' => 0,
|
||||
'body' => $value,
|
||||
'is_internal' => 1,
|
||||
'created' => $now,
|
||||
];
|
||||
$db->insertObject('#__mokowaas_ticket_replies', $reply, 'id');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all automation rules.
|
||||
*/
|
||||
public function getAutomationRules(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_ticket_automation'))
|
||||
->order($db->quoteName('ordering') . ' ASC')
|
||||
);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Akeeba Ticket System Importer
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Check if ATS tables exist and return counts.
|
||||
*/
|
||||
public function checkAtsAvailable(): ?object
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
try
|
||||
{
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__ats_tickets');
|
||||
$tickets = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__ats_posts');
|
||||
$posts = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__ats_cannedreplies');
|
||||
$canned = (int) $db->loadResult();
|
||||
|
||||
return (object) ['tickets' => $tickets, 'posts' => $posts, 'canned' => $canned];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import tickets, replies, and canned responses from Akeeba Ticket System.
|
||||
*/
|
||||
public function importFromAts(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$results = ['tickets' => 0, 'replies' => 0, 'canned' => 0, 'errors' => []];
|
||||
|
||||
try
|
||||
{
|
||||
// Status mapping: ATS → MokoWaaS
|
||||
$statusMap = [
|
||||
'O' => 'open', // Open
|
||||
'P' => 'in_progress', // Pending (staff action needed)
|
||||
'C' => 'closed', // Closed
|
||||
];
|
||||
// Numeric statuses 1-99 are custom — map to open
|
||||
for ($i = 1; $i <= 99; $i++)
|
||||
{
|
||||
$statusMap[(string) $i] = 'open';
|
||||
}
|
||||
|
||||
// Priority mapping: ATS uses 1-5, we use enum
|
||||
$priorityMap = [
|
||||
1 => 'low',
|
||||
2 => 'low',
|
||||
3 => 'normal',
|
||||
4 => 'high',
|
||||
5 => 'urgent',
|
||||
];
|
||||
|
||||
// Category mapping: ATS uses Joomla categories, map catid to our category
|
||||
// Default all to General Support (1) — admin can reassign later
|
||||
$defaultCategory = 1;
|
||||
|
||||
// Import canned replies first
|
||||
$db->setQuery('SELECT * FROM #__ats_cannedreplies WHERE enabled = 1 ORDER BY ordering');
|
||||
$atsCanned = $db->loadObjectList() ?: [];
|
||||
|
||||
foreach ($atsCanned as $c)
|
||||
{
|
||||
$exists = $db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from('#__mokowaas_ticket_canned')
|
||||
->where($db->quoteName('title') . ' = ' . $db->quote($c->title))
|
||||
)->loadResult();
|
||||
|
||||
if ((int) $exists > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = (object) [
|
||||
'title' => $c->title,
|
||||
'body' => strip_tags($c->reply ?? ''),
|
||||
'category_id' => null,
|
||||
'ordering' => (int) ($c->ordering ?? 0),
|
||||
];
|
||||
$db->insertObject('#__mokowaas_ticket_canned', $row, 'id');
|
||||
$results['canned']++;
|
||||
}
|
||||
|
||||
// Import tickets
|
||||
$db->setQuery('SELECT * FROM #__ats_tickets ORDER BY id');
|
||||
$atsTickets = $db->loadObjectList() ?: [];
|
||||
|
||||
$ticketIdMap = []; // ATS id → MokoWaaS id
|
||||
|
||||
foreach ($atsTickets as $t)
|
||||
{
|
||||
// Skip if already imported (check by subject + created_by + created)
|
||||
$exists = $db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from('#__mokowaas_tickets')
|
||||
->where($db->quoteName('subject') . ' = ' . $db->quote($t->title))
|
||||
->where($db->quoteName('created_by') . ' = ' . (int) $t->created_by)
|
||||
)->loadResult();
|
||||
|
||||
if ((int) $exists > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $statusMap[$t->status] ?? 'open';
|
||||
$priority = $priorityMap[(int) $t->priority] ?? 'normal';
|
||||
|
||||
$row = (object) [
|
||||
'subject' => $t->title,
|
||||
'body' => '',
|
||||
'status' => $status,
|
||||
'priority' => $priority,
|
||||
'category_id' => $defaultCategory,
|
||||
'created_by' => (int) $t->created_by,
|
||||
'assigned_to' => (int) $t->assigned_to ?: null,
|
||||
'created' => $t->created ?: Factory::getDate()->toSql(),
|
||||
'modified' => $t->modified,
|
||||
'resolved' => $status === 'closed' ? ($t->modified ?: $t->created) : null,
|
||||
'closed' => $status === 'closed' ? ($t->modified ?: $t->created) : null,
|
||||
'sla_responded' => 1,
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokowaas_tickets', $row, 'id');
|
||||
$ticketIdMap[(int) $t->id] = (int) $row->id;
|
||||
$results['tickets']++;
|
||||
}
|
||||
|
||||
// Import posts (replies)
|
||||
$db->setQuery('SELECT * FROM #__ats_posts ORDER BY id');
|
||||
$atsPosts = $db->loadObjectList() ?: [];
|
||||
|
||||
foreach ($atsPosts as $p)
|
||||
{
|
||||
$newTicketId = $ticketIdMap[(int) $p->ticket_id] ?? null;
|
||||
|
||||
if (!$newTicketId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// First post of a ticket is usually the ticket body — update the ticket
|
||||
if (empty($results['first_post_' . $p->ticket_id]))
|
||||
{
|
||||
$results['first_post_' . $p->ticket_id] = true;
|
||||
$body = strip_tags($p->content_html ?? '');
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update('#__mokowaas_tickets')
|
||||
->set($db->quoteName('body') . ' = ' . $db->quote($body))
|
||||
->where($db->quoteName('id') . ' = ' . $newTicketId)
|
||||
)->execute();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = (object) [
|
||||
'ticket_id' => $newTicketId,
|
||||
'user_id' => (int) $p->created_by,
|
||||
'body' => strip_tags($p->content_html ?? ''),
|
||||
'is_internal' => 0,
|
||||
'created' => $p->created ?: Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokowaas_ticket_replies', $row, 'id');
|
||||
$results['replies']++;
|
||||
}
|
||||
|
||||
// Clean up temp tracking keys
|
||||
foreach (array_keys($results) as $k)
|
||||
{
|
||||
if (str_starts_with($k, 'first_post_'))
|
||||
{
|
||||
unset($results[$k]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf(
|
||||
'Imported %d tickets, %d replies, %d canned responses from Akeeba Ticket System.',
|
||||
$results['tickets'], $results['replies'], $results['canned']
|
||||
),
|
||||
'counts' => $results,
|
||||
];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,19 +35,6 @@ class HtmlView extends BaseHtmlView
|
||||
$this->checkedOutItems = $model->getCheckedOutItems();
|
||||
$this->wafBlocks = $model->getRecentWafBlocks(5);
|
||||
|
||||
// Check for importable Akeeba data
|
||||
try
|
||||
{
|
||||
$importModel = new \Moko\Component\MokoWaaS\Administrator\Model\ImportModel();
|
||||
$this->adminToolsAvailable = $importModel->checkAdminToolsAvailable();
|
||||
$this->atsAvailable = $importModel->checkAtsAvailable();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->adminToolsAvailable = null;
|
||||
$this->atsAvailable = null;
|
||||
}
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Htaccess;
|
||||
|
||||
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 $options = [];
|
||||
protected $preview = '';
|
||||
protected $nginxPreview = '';
|
||||
protected $currentHtaccess = '';
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->getModel();
|
||||
|
||||
$this->options = $model->getOptions();
|
||||
$this->preview = $model->generateHtaccess($this->options);
|
||||
$this->nginxPreview = $model->generateNginx($this->options);
|
||||
$this->currentHtaccess = $model->readCurrentHtaccess();
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOWAAS_HTACCESS_TITLE'), 'file-code');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\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 = [];
|
||||
|
||||
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));
|
||||
|
||||
if (!$this->ticket)
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
|
||||
Factory::getApplication()->redirect('index.php?option=com_mokowaas&view=tickets');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/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_mokowaas&view=tickets');
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\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;
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->getModel();
|
||||
$app = Factory::getApplication();
|
||||
|
||||
$filters = [
|
||||
'status' => $app->getInput()->getString('filter_status', ''),
|
||||
'priority' => $app->getInput()->getString('filter_priority', ''),
|
||||
'category_id' => $app->getInput()->getInt('filter_category', 0),
|
||||
];
|
||||
|
||||
$this->tickets = $model->getTickets($filters);
|
||||
$this->categories = $model->getCategories();
|
||||
$this->statusCounts = $model->getStatusCounts();
|
||||
$this->overdue = $model->getOverdueTickets();
|
||||
$this->atsAvailable = $model->checkAtsAvailable();
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOWAAS_TICKETS_TITLE'), 'headphones');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,6 @@ $siteInfo = $this->siteInfo;
|
||||
$plugins = $this->plugins;
|
||||
$recentLogins = $this->recentLogins;
|
||||
$pendingUpdates = $this->pendingUpdates;
|
||||
$adminToolsAvail = $this->adminToolsAvailable ?? null;
|
||||
$atsAvail = $this->atsAvailable ?? null;
|
||||
$checkedOut = $this->checkedOutItems;
|
||||
$wafBlocks = $this->wafBlocks;
|
||||
$token = Session::getFormToken();
|
||||
@@ -68,86 +66,26 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($adminToolsAvail || $atsAvail): ?>
|
||||
<!-- Akeeba Import Banner -->
|
||||
<div class="alert alert-info d-flex flex-wrap align-items-center gap-3 mb-4">
|
||||
<span class="icon-info-circle" style="font-size:1.25rem"></span>
|
||||
<strong>Akeeba data detected — import into MokoWaaS:</strong>
|
||||
<?php if ($adminToolsAvail): ?>
|
||||
<button type="button" class="btn btn-sm btn-info" id="btn-import-admintools"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.importAdminTools&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-shield-alt"></span> Import Admin Tools Settings
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($atsAvail): ?>
|
||||
<button type="button" class="btn btn-sm btn-info" id="btn-import-ats-dash"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.importAts&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-headphones"></span> Import Tickets (<?php echo $atsAvail->tickets; ?> tickets)
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Quick Actions (large buttons) -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<button type="button" class="btn btn-outline-primary w-100 py-3" id="mokowaas-btn-cache"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.clearCache&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-bolt d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Clear Cache
|
||||
<span class="icon-trash d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
<?php echo Text::_('COM_MOKOWAAS_CLEAR_CACHE'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="btn btn-outline-primary w-100 py-3">
|
||||
<span class="icon-refresh d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Check Updates
|
||||
<?php echo Text::_('COM_MOKOWAAS_CHECK_UPDATES'); ?>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=extensions'); ?>" class="btn btn-outline-primary w-100 py-3">
|
||||
<span class="icon-puzzle-piece d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Moko Extensions
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_checkin'); ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-check-square d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Global Check-in
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_actionlogs'); ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-list d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
View Logs
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_scheduler'); ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-clock d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Scheduled Tasks
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<?php
|
||||
// Use Community Builder if available, otherwise Joomla user manager
|
||||
$useCB = file_exists(JPATH_ADMINISTRATOR . '/components/com_comprofiler/comprofiler.php');
|
||||
$userUrl = $useCB
|
||||
? Route::_('index.php?option=com_comprofiler&task=showusers')
|
||||
: Route::_('index.php?option=com_users');
|
||||
$userLabel = $useCB ? 'Community Builder' : 'User Manager';
|
||||
?>
|
||||
<a href="<?php echo $userUrl; ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-users d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
<?php echo $userLabel; ?>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_redirect'); ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-arrow-right d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Redirects
|
||||
<?php echo Text::_('COM_MOKOWAAS_EXTENSIONS_LINK'); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,10 +122,6 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
|
||||
<?php if ($plugin->protected): ?>
|
||||
<span class="badge bg-dark"><?php echo Text::_('COM_MOKOWAAS_PROTECTED'); ?></span>
|
||||
<?php elseif ($plugin->configure_only): ?>
|
||||
<span class="badge bg-<?php echo $plugin->enabled ? 'success' : 'secondary'; ?>">
|
||||
<?php echo $plugin->enabled ? Text::_('COM_MOKOWAAS_ENABLED') : Text::_('COM_MOKOWAAS_DISABLED'); ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input mokowaas-toggle" role="switch"
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @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\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$opts = $this->options;
|
||||
$preview = $this->preview;
|
||||
$nginx = $this->nginxPreview;
|
||||
$current = $this->currentHtaccess;
|
||||
$token = Session::getFormToken();
|
||||
$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveHtaccess&format=json');
|
||||
$genUrl = Route::_('index.php?option=com_mokowaas&task=display.generateHtaccess&format=json');
|
||||
|
||||
// Helper for toggle switch
|
||||
$sw = function($name, $label, $desc = '') use ($opts) {
|
||||
$checked = !empty($opts[$name]) ? 'checked' : '';
|
||||
echo '<div class="d-flex justify-content-between align-items-center py-2 border-bottom">';
|
||||
echo '<div><strong>' . htmlspecialchars($label) . '</strong>';
|
||||
if ($desc) echo '<br><small class="text-muted">' . htmlspecialchars($desc) . '</small>';
|
||||
echo '</div>';
|
||||
echo '<div class="form-check form-switch">';
|
||||
echo '<input type="checkbox" class="form-check-input htaccess-opt" name="' . $name . '" id="htopt-' . $name . '" ' . $checked . '>';
|
||||
echo '</div></div>';
|
||||
};
|
||||
?>
|
||||
|
||||
<div id="mokowaas-htaccess">
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-htaccess" role="tab">.htaccess</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-nginx" role="tab">NginX</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-current" role="tab">Current File</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- .htaccess Tab -->
|
||||
<div class="tab-pane fade show active" id="tab-htaccess" role="tabpanel">
|
||||
<div class="row">
|
||||
<!-- Left: Options -->
|
||||
<div class="col-12 col-xl-6">
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong><span class="icon-shield-alt"></span> Security</strong></div>
|
||||
<div class="card-body">
|
||||
<?php $sw('disable_directory_listing', 'Disable Directory Listing', 'Options -Indexes'); ?>
|
||||
<?php $sw('block_sensitive_files', 'Block Sensitive Files', 'htaccess.txt, configuration.php-dist, etc.'); ?>
|
||||
<?php $sw('block_php_in_uploads', 'Block PHP in Uploads', 'Prevent .php in images/, media/, tmp/'); ?>
|
||||
<?php $sw('disable_server_signature', 'Hide Server Signature', 'ServerSignature Off, remove X-Powered-By'); ?>
|
||||
<?php $sw('prevent_clickjacking', 'Clickjacking Protection', 'X-Frame-Options: SAMEORIGIN'); ?>
|
||||
<?php $sw('prevent_mime_sniffing', 'MIME Sniffing Prevention', 'X-Content-Type-Options: nosniff'); ?>
|
||||
<?php $sw('xss_protection', 'XSS Protection Header', 'X-XSS-Protection: 1; mode=block'); ?>
|
||||
<?php $sw('disable_trace_track', 'Disable TRACE/TRACK', 'Block HTTP TRACE and TRACK methods'); ?>
|
||||
|
||||
<div class="py-2 border-bottom">
|
||||
<label class="form-label fw-bold" for="htopt-referrer_policy">Referrer Policy</label>
|
||||
<select class="form-select form-select-sm htaccess-opt" name="referrer_policy" id="htopt-referrer_policy">
|
||||
<option value="off" <?php echo ($opts['referrer_policy'] ?? '') === 'off' ? 'selected' : ''; ?>>Off</option>
|
||||
<option value="no-referrer" <?php echo ($opts['referrer_policy'] ?? '') === 'no-referrer' ? 'selected' : ''; ?>>no-referrer</option>
|
||||
<option value="same-origin" <?php echo ($opts['referrer_policy'] ?? '') === 'same-origin' ? 'selected' : ''; ?>>same-origin</option>
|
||||
<option value="strict-origin-when-cross-origin" <?php echo ($opts['referrer_policy'] ?? '') === 'strict-origin-when-cross-origin' ? 'selected' : ''; ?>>strict-origin-when-cross-origin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<?php $sw('hsts_enabled', 'HSTS (Force HTTPS)', 'Strict-Transport-Security header'); ?>
|
||||
<div class="ps-4 <?php echo empty($opts['hsts_enabled']) ? 'd-none' : ''; ?>" id="hsts-options">
|
||||
<div class="row g-2 py-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Max Age (seconds)</label>
|
||||
<input type="number" class="form-control form-control-sm htaccess-opt" name="hsts_max_age" value="<?php echo (int) ($opts['hsts_max_age'] ?? 31536000); ?>">
|
||||
</div>
|
||||
<div class="col-6 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input htaccess-opt" name="hsts_subdomains" id="htopt-hsts_sub" <?php echo !empty($opts['hsts_subdomains']) ? 'checked' : ''; ?>>
|
||||
<label class="form-check-label small" for="htopt-hsts_sub">Include Subdomains</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php $sw('csp_enabled', 'Content Security Policy', 'CSP header'); ?>
|
||||
<div class="ps-4 <?php echo empty($opts['csp_enabled']) ? 'd-none' : ''; ?>" id="csp-options">
|
||||
<textarea class="form-control form-control-sm htaccess-opt mt-1" name="csp_value" rows="2" placeholder="default-src 'self'; script-src 'self' 'unsafe-inline'"><?php echo htmlspecialchars($opts['csp_value'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
|
||||
<?php $sw('permissions_policy', 'Permissions Policy', 'Camera, microphone, geolocation controls'); ?>
|
||||
<div class="ps-4 <?php echo empty($opts['permissions_policy']) ? 'd-none' : ''; ?>" id="perms-options">
|
||||
<textarea class="form-control form-control-sm htaccess-opt mt-1" name="permissions_value" rows="2" placeholder="camera=(), microphone=(), geolocation=()"><?php echo htmlspecialchars($opts['permissions_value'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong><span class="icon-bolt"></span> Performance</strong></div>
|
||||
<div class="card-body">
|
||||
<?php $sw('enable_gzip', 'GZip Compression', 'Compress CSS, JS, HTML, XML, JSON'); ?>
|
||||
<?php $sw('enable_expires', 'Browser Caching', 'Set expiration headers for static files'); ?>
|
||||
<div class="ps-4 <?php echo empty($opts['enable_expires']) ? 'd-none' : ''; ?>" id="expires-options">
|
||||
<div class="row g-2 py-2">
|
||||
<div class="col-4">
|
||||
<label class="form-label small">HTML (sec)</label>
|
||||
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_html" value="<?php echo (int) ($opts['expires_html'] ?? 3600); ?>">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small">CSS/JS (sec)</label>
|
||||
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_css_js" value="<?php echo (int) ($opts['expires_css_js'] ?? 2592000); ?>">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small">Images (sec)</label>
|
||||
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_images" value="<?php echo (int) ($opts['expires_images'] ?? 31536000); ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php $sw('etag_control', 'Disable ETags', 'For load-balanced environments'); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong><span class="icon-search"></span> SEO / Redirects</strong></div>
|
||||
<div class="card-body">
|
||||
<div class="py-2 border-bottom">
|
||||
<label class="form-label fw-bold">WWW Redirect</label>
|
||||
<select class="form-select form-select-sm htaccess-opt" name="www_redirect">
|
||||
<option value="off" <?php echo ($opts['www_redirect'] ?? 'off') === 'off' ? 'selected' : ''; ?>>Off</option>
|
||||
<option value="www" <?php echo ($opts['www_redirect'] ?? '') === 'www' ? 'selected' : ''; ?>>Force www</option>
|
||||
<option value="non-www" <?php echo ($opts['www_redirect'] ?? '') === 'non-www' ? 'selected' : ''; ?>>Force non-www</option>
|
||||
</select>
|
||||
</div>
|
||||
<?php $sw('redirect_index_php', 'Redirect /index.php to /', 'SEO-friendly root redirect'); ?>
|
||||
<?php $sw('force_trailing_slash', 'Force Trailing Slash', 'Append / to URLs without file extension'); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong><span class="icon-code"></span> Custom Rules</strong></div>
|
||||
<div class="card-body">
|
||||
<textarea class="form-control htaccess-opt" name="custom_rules" rows="4" placeholder="# Add custom Apache directives here"><?php echo htmlspecialchars($opts['custom_rules'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Preview -->
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card mb-3 sticky-top" style="top:1rem">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Preview</strong>
|
||||
<span class="badge bg-secondary" id="htaccess-line-count"><?php echo substr_count($preview, "\n"); ?> lines</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<textarea id="htaccess-preview" class="form-control font-monospace border-0" rows="30" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($preview); ?></textarea>
|
||||
</div>
|
||||
<div class="card-footer d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary" id="htaccess-save"
|
||||
data-url="<?php echo $saveUrl; ?>" data-token="<?php echo $token; ?>">
|
||||
<span class="icon-save"></span> Save to .htaccess
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="htaccess-download">
|
||||
<span class="icon-download"></span> Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NginX Tab -->
|
||||
<div class="tab-pane fade" id="tab-nginx" role="tabpanel">
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>NginX Configuration Snippet</strong></div>
|
||||
<div class="card-body p-0">
|
||||
<textarea id="nginx-preview" class="form-control font-monospace border-0" rows="25" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($nginx); ?></textarea>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" id="nginx-download">
|
||||
<span class="icon-download"></span> Download NginX Config
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current File Tab -->
|
||||
<div class="tab-pane fade" id="tab-current" role="tabpanel">
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>Current .htaccess on Disk</strong></div>
|
||||
<div class="card-body p-0">
|
||||
<textarea class="form-control font-monospace border-0" rows="25" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($current); ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var saveBtn = document.getElementById('htaccess-save');
|
||||
var preview = document.getElementById('htaccess-preview');
|
||||
var lineCount = document.getElementById('htaccess-line-count');
|
||||
|
||||
// Toggle sub-option visibility
|
||||
document.getElementById('htopt-hsts_enabled').addEventListener('change', function() {
|
||||
document.getElementById('hsts-options').classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
document.getElementById('htopt-csp_enabled').addEventListener('change', function() {
|
||||
document.getElementById('csp-options').classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
document.getElementById('htopt-permissions_policy').addEventListener('change', function() {
|
||||
document.getElementById('perms-options').classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
document.getElementById('htopt-enable_expires') && document.getElementById('htopt-enable_expires').addEventListener('change', function() {
|
||||
document.getElementById('expires-options').classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
|
||||
// Regenerate preview on any option change
|
||||
document.querySelectorAll('.htaccess-opt').forEach(function(el) {
|
||||
el.addEventListener('change', regeneratePreview);
|
||||
el.addEventListener('input', regeneratePreview);
|
||||
});
|
||||
|
||||
function collectOptions() {
|
||||
var opts = {};
|
||||
document.querySelectorAll('.htaccess-opt').forEach(function(el) {
|
||||
if (el.type === 'checkbox') {
|
||||
opts[el.name] = el.checked ? 1 : 0;
|
||||
} else {
|
||||
opts[el.name] = el.value;
|
||||
}
|
||||
});
|
||||
return opts;
|
||||
}
|
||||
|
||||
var debounceTimer;
|
||||
function regeneratePreview() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(function() {
|
||||
var fd = new FormData();
|
||||
var opts = collectOptions();
|
||||
for (var k in opts) fd.append(k, opts[k]);
|
||||
fd.append('<?php echo $token; ?>', '1');
|
||||
|
||||
fetch('<?php echo $genUrl; ?>', {
|
||||
method: 'POST', body: fd,
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.htaccess) {
|
||||
preview.value = d.htaccess;
|
||||
lineCount.textContent = d.htaccess.split('\n').length + ' lines';
|
||||
}
|
||||
if (d.nginx) {
|
||||
document.getElementById('nginx-preview').value = d.nginx;
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Save to disk
|
||||
saveBtn.addEventListener('click', function() {
|
||||
if (!confirm('This will overwrite your current .htaccess file. A backup will be created at .htaccess.mokowaas.bak. Continue?')) return;
|
||||
var btn = this;
|
||||
btn.disabled = true;
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('content', preview.value);
|
||||
var opts = collectOptions();
|
||||
for (var k in opts) fd.append('opt_' + k, opts[k]);
|
||||
fd.append('<?php echo $token; ?>', '1');
|
||||
|
||||
fetch(btn.dataset.url, {
|
||||
method: 'POST', body: fd,
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success) Joomla.renderMessages({message: [d.message]});
|
||||
else Joomla.renderMessages({error: [d.message]});
|
||||
})
|
||||
.catch(function() { Joomla.renderMessages({error: ['Network error']}); })
|
||||
.finally(function() { btn.disabled = false; });
|
||||
});
|
||||
|
||||
// Download buttons
|
||||
function downloadText(content, filename) {
|
||||
var blob = new Blob([content], {type: 'text/plain'});
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
document.getElementById('htaccess-download').addEventListener('click', function() {
|
||||
downloadText(preview.value, '.htaccess');
|
||||
});
|
||||
document.getElementById('nginx-download').addEventListener('click', function() {
|
||||
downloadText(document.getElementById('nginx-preview').value, 'mokowaas-nginx.conf');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -1,198 +0,0 @@
|
||||
<?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();
|
||||
|
||||
$statusBadge = [
|
||||
'open' => 'bg-primary', 'in_progress' => 'bg-info',
|
||||
'waiting' => 'bg-warning text-dark', 'resolved' => 'bg-success', 'closed' => 'bg-secondary',
|
||||
];
|
||||
$priorityBadge = [
|
||||
'low' => 'bg-secondary', 'normal' => 'bg-primary', 'high' => 'bg-warning text-dark', 'urgent' => 'bg-danger',
|
||||
];
|
||||
?>
|
||||
|
||||
<div id="mokowaas-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)); ?></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)); ?></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="d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary" id="btn-reply"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&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_mokowaas&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 $statusBadge[$t->status] ?? ''; ?>"><?php echo ucwords(str_replace('_', ' ', $t->status)); ?></span></td></tr>
|
||||
<tr><td class="text-muted">Priority</td><td><span class="badge <?php echo $priorityBadge[$t->priority] ?? ''; ?>"><?php echo ucfirst($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 echo $this->escape($t->assigned_to_name ?? 'Unassigned'); ?></td></tr>
|
||||
<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 = !\in_array($t->status, ['resolved','closed']) && strtotime($t->sla_resolution_due) < time();
|
||||
?>
|
||||
<span class="<?php echo \in_array($t->status, ['resolved','closed']) ? 'text-success' : ($resolutionOverdue ? 'text-danger fw-bold' : ''); ?>">
|
||||
<?php echo \in_array($t->status, ['resolved','closed']) ? 'Met' : HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?>
|
||||
<?php echo $resolutionOverdue ? ' OVERDUE' : ''; ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</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 (['open' => 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?>
|
||||
<?php if ($s !== $t->status): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-<?php echo $s === 'closed' ? 'danger' : ($s === 'resolved' ? 'success' : 'secondary'); ?> btn-status"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.updateTicketStatus&format=json'); ?>"
|
||||
data-ticket="<?php echo $t->id; ?>" data-status="<?php echo $s; ?>" data-token="<?php echo $token; ?>">
|
||||
<?php echo $label; ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
document.querySelectorAll('#btn-reply, #btn-internal').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var body = document.getElementById('reply-body').value.trim();
|
||||
if (!body) return;
|
||||
var el = this;
|
||||
el.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', el.dataset.ticket);
|
||||
fd.append('body', body);
|
||||
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) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
|
||||
.finally(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; });
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -1,290 +0,0 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
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;
|
||||
$counts = $this->statusCounts;
|
||||
$overdue = $this->overdue;
|
||||
$atsAvailable = $this->atsAvailable;
|
||||
$token = Session::getFormToken();
|
||||
|
||||
$statusBadge = [
|
||||
'open' => 'bg-primary',
|
||||
'in_progress' => 'bg-info',
|
||||
'waiting' => 'bg-warning text-dark',
|
||||
'resolved' => 'bg-success',
|
||||
'closed' => 'bg-secondary',
|
||||
];
|
||||
|
||||
$priorityBadge = [
|
||||
'low' => 'bg-secondary',
|
||||
'normal' => 'bg-primary',
|
||||
'high' => 'bg-warning text-dark',
|
||||
'urgent' => 'bg-danger',
|
||||
];
|
||||
?>
|
||||
|
||||
<div id="mokowaas-tickets">
|
||||
<!-- Status summary cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->open; ?></span><small class="text-muted">Open</small></div></div>
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->in_progress; ?></span><small class="text-muted">In Progress</small></div></div>
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->waiting; ?></span><small class="text-muted">Waiting</small></div></div>
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->resolved; ?></span><small class="text-muted">Resolved</small></div></div>
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->closed; ?></span><small class="text-muted">Closed</small></div></div>
|
||||
<?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_mokowaas&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_mokowaas">
|
||||
<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 (['open','in_progress','waiting','resolved','closed'] as $s): ?>
|
||||
<option value="<?php echo $s; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $s ? 'selected' : ''; ?>><?php echo ucwords(str_replace('_', ' ', $s)); ?></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 (['low','normal','high','urgent'] as $p): ?>
|
||||
<option value="<?php echo $p; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_priority') === $p ? 'selected' : ''; ?>><?php echo ucfirst($p); ?></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>Created By</th>
|
||||
<th>Assigned To</th>
|
||||
<th>Created</th>
|
||||
<th>SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($tickets)): ?>
|
||||
<tr><td colspan="9" 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 && !\in_array($t->status, ['resolved','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_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $this->escape(mb_substr($t->subject, 0, 60)); ?></a></td>
|
||||
<td><span class="badge <?php echo $statusBadge[$t->status] ?? 'bg-secondary'; ?>"><?php echo ucwords(str_replace('_', ' ', $t->status)); ?></span></td>
|
||||
<td><span class="badge <?php echo $priorityBadge[$t->priority] ?? 'bg-secondary'; ?>"><?php echo ucfirst($t->priority); ?></span></td>
|
||||
<td><?php echo $this->escape($t->category_title ?? '—'); ?></td>
|
||||
<td><?php echo $this->escape($t->created_by_name ?? ''); ?></td>
|
||||
<td><?php echo $this->escape($t->assigned_to_name ?? '<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_mokowaas&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-6">
|
||||
<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-6">
|
||||
<label class="form-label">Priority</label>
|
||||
<select name="priority" class="form-select">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">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_mokowaas&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_mokowaas&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>
|
||||
@@ -109,26 +109,4 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Akeeba import buttons
|
||||
['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) {
|
||||
var btn = document.getElementById(id);
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
if (!confirm('Import Akeeba data into MokoWaaS? Akeeba extensions will be disabled after import.')) return;
|
||||
el.disabled = true;
|
||||
var origText = el.textContent;
|
||||
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]}); setTimeout(function(){location.reload()}, 2000); }
|
||||
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = origText; }
|
||||
})
|
||||
.catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; el.textContent = origText; });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,44 +20,21 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.00</version>
|
||||
<description>MokoWaaS admin dashboard and REST API. Provides a control panel for managing MokoWaaS feature plugins, site health monitoring, and remote management endpoints.</description>
|
||||
|
||||
<namespace path="src">Moko\Component\MokoWaaS</namespace>
|
||||
|
||||
<administration>
|
||||
<menu img="class:cogs">MokoWaaS</menu>
|
||||
<submenu>
|
||||
<menu link="option=com_mokowaas" img="class:cogs">COM_MOKOWAAS_MENU_DASHBOARD</menu>
|
||||
<menu link="option=com_mokowaas&view=extensions" img="class:puzzle-piece">COM_MOKOWAAS_MENU_EXTENSIONS</menu>
|
||||
<menu link="option=com_mokowaas&view=tickets" img="class:headphones">COM_MOKOWAAS_MENU_TICKETS</menu>
|
||||
<menu link="option=com_mokowaas&view=htaccess" img="class:file-code">COM_MOKOWAAS_MENU_HTACCESS</menu>
|
||||
<menu link="option=com_plugins&filter[folder]=system&filter[search]=mokowaas" img="class:power-off">COM_MOKOWAAS_MENU_PLUGINS</menu>
|
||||
<menu link="option=com_installer&view=update" img="class:refresh">COM_MOKOWAAS_MENU_UPDATES</menu>
|
||||
<menu link="option=com_checkin" img="class:check-square">COM_MOKOWAAS_MENU_CHECKIN</menu>
|
||||
<menu link="option=com_cache" img="class:bolt">COM_MOKOWAAS_MENU_CACHE</menu>
|
||||
</submenu>
|
||||
<files folder="admin">
|
||||
<filename>access.xml</filename>
|
||||
<folder>language</folder>
|
||||
<folder>services</folder>
|
||||
<folder>sql</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
</administration>
|
||||
|
||||
<files folder="site">
|
||||
<folder>language</folder>
|
||||
<folder>services</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
|
||||
<install>
|
||||
<sql><file driver="mysql" charset="utf8">admin/sql/install.mysql.sql</file></sql>
|
||||
</install>
|
||||
|
||||
<api>
|
||||
<files folder="api">
|
||||
<folder>src</folder>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
; MokoWaaS Customer Portal - Language Strings
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
COM_MOKOWAAS_PORTAL_TITLE="Support Portal"
|
||||
COM_MOKOWAAS_PORTAL_MY_TICKETS="My Support Tickets"
|
||||
COM_MOKOWAAS_PORTAL_NEW_TICKET="New Ticket"
|
||||
COM_MOKOWAAS_PORTAL_SUBMIT="Submit Ticket"
|
||||
COM_MOKOWAAS_PORTAL_REPLY="Send Reply"
|
||||
COM_MOKOWAAS_PORTAL_NO_TICKETS="You haven't submitted any support tickets yet."
|
||||
COM_MOKOWAAS_PORTAL_LOGIN_REQUIRED="Please log in to access the support portal."
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas.site
|
||||
* @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\Dispatcher\ComponentDispatcherFactoryInterface;
|
||||
use Joomla\CMS\Extension\ComponentInterface;
|
||||
use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory;
|
||||
use Joomla\CMS\Extension\Service\Provider\MVCFactory;
|
||||
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
|
||||
return new class implements ServiceProviderInterface
|
||||
{
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoWaaS'));
|
||||
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoWaaS'));
|
||||
|
||||
$container->set(
|
||||
ComponentInterface::class,
|
||||
function (Container $container) {
|
||||
$component = new \Joomla\CMS\Extension\MVCComponent(
|
||||
$container->get(ComponentDispatcherFactoryInterface::class)
|
||||
);
|
||||
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
|
||||
|
||||
return $component;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,239 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas.site
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Site\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
class DisplayController extends BaseController
|
||||
{
|
||||
protected $default_view = 'tickets';
|
||||
|
||||
public function display($cachable = false, $urlparams = [])
|
||||
{
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
if ($user->guest)
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage('Please log in to access the support portal.', 'warning');
|
||||
Factory::getApplication()->redirect(Route::_(
|
||||
'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=tickets'),
|
||||
false
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return parent::display($cachable, $urlparams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a new ticket.
|
||||
*/
|
||||
public function submitTicket()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
if ($user->guest)
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
// Use admin TicketsModel
|
||||
$model = $this->getModel('Tickets', 'Administrator');
|
||||
|
||||
$this->jsonResponse($model->createTicket([
|
||||
'subject' => $input->getString('subject', ''),
|
||||
'body' => $input->getRaw('body', ''),
|
||||
'priority' => $input->getString('priority', 'normal'),
|
||||
'category_id' => $input->getInt('category_id', 0),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a reply.
|
||||
*/
|
||||
public function submitReply()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
if ($user->guest)
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
|
||||
}
|
||||
|
||||
$ticketId = $input->getInt('ticket_id', 0);
|
||||
$model = $this->getModel('Tickets', 'Administrator');
|
||||
$ticket = $model->getTicket($ticketId);
|
||||
|
||||
if (!$ticket)
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Ticket not found.']);
|
||||
}
|
||||
|
||||
// Customers can only reply to their own tickets; staff can reply to any
|
||||
if ((int) $ticket->created_by !== $user->id && !$this->isStaff($user))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
|
||||
}
|
||||
|
||||
// Staff replies from frontend are not internal notes
|
||||
$this->jsonResponse($model->addReply(
|
||||
$ticketId,
|
||||
$input->getRaw('body', ''),
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update ticket status (staff/manager only from frontend).
|
||||
*/
|
||||
public function updateStatus()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
if (!$this->isStaff($user))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$model = $this->getModel('Tickets', 'Administrator');
|
||||
|
||||
$this->jsonResponse($model->updateStatus(
|
||||
$input->getInt('ticket_id', 0),
|
||||
$input->getString('status', '')
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a ticket (manager only from frontend).
|
||||
*/
|
||||
public function assignTicket()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
if (!$user->authorise('mokowaas.tickets.assign', 'com_mokowaas'))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$ticketId = $input->getInt('ticket_id', 0);
|
||||
$assignTo = $input->getInt('assigned_to', 0);
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_tickets'))
|
||||
->set($db->quoteName('assigned_to') . ' = ' . ($assignTo ?: 'NULL'))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql()))
|
||||
->where($db->quoteName('id') . ' = ' . $ticketId)
|
||||
)->execute();
|
||||
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Ticket assigned.']);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is support staff (can manage tickets beyond their own).
|
||||
*/
|
||||
private function isStaff($user): bool
|
||||
{
|
||||
if ($user->guest)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Super admins always staff
|
||||
if ($user->authorise('core.admin'))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Anyone with mokowaas.tickets ACL on the component is staff
|
||||
return $user->authorise('mokowaas.tickets', 'com_mokowaas');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search KB articles via Smart Search (com_finder).
|
||||
*/
|
||||
public function searchKb()
|
||||
{
|
||||
$query = Factory::getApplication()->getInput()->getString('q', '');
|
||||
|
||||
if (strlen($query) < 3)
|
||||
{
|
||||
$this->jsonResponse(['results' => []]);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
|
||||
|
||||
$results = $db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('l.link_id'),
|
||||
$db->quoteName('l.title'),
|
||||
$db->quoteName('l.url'),
|
||||
$db->quoteName('l.description'),
|
||||
])
|
||||
->from($db->quoteName('#__finder_links', 'l'))
|
||||
->where($db->quoteName('l.published') . ' = 1')
|
||||
->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped
|
||||
. ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')')
|
||||
->order($db->quoteName('l.title') . ' ASC')
|
||||
->setLimit(8)
|
||||
)->loadObjectList() ?: [];
|
||||
|
||||
foreach ($results as $r)
|
||||
{
|
||||
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
|
||||
}
|
||||
|
||||
$this->jsonResponse(['results' => $results]);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->jsonResponse(['results' => []]);
|
||||
}
|
||||
}
|
||||
|
||||
private function jsonResponse(array $data): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode($data);
|
||||
$app->close();
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas.site
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Site\View\Ticket;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $ticket;
|
||||
protected $isStaff = false;
|
||||
protected $canAssign = false;
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
|
||||
$this->isStaff = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets', 'com_mokowaas');
|
||||
$this->canAssign = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets.assign', 'com_mokowaas');
|
||||
|
||||
// Get ticket — staff see any, customers see only their own
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('t') . '.*',
|
||||
$db->quoteName('c.title', 'category_title'),
|
||||
$db->quoteName('u.name', 'created_by_name'),
|
||||
$db->quoteName('u.email', 'created_by_email'),
|
||||
$db->quoteName('a.name', 'assigned_to_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokowaas_tickets', 't'))
|
||||
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to')
|
||||
->where($db->quoteName('t.id') . ' = ' . $id);
|
||||
|
||||
if (!$this->isStaff)
|
||||
{
|
||||
$query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
$this->ticket = $db->loadObject();
|
||||
|
||||
if (!$this->ticket)
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
|
||||
Factory::getApplication()->redirect(Route::_('index.php?option=com_mokowaas&view=tickets', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Load replies — staff see internal notes, customers don't
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('r') . '.*',
|
||||
$db->quoteName('u.name', 'user_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokowaas_ticket_replies', 'r'))
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
|
||||
->where($db->quoteName('r.ticket_id') . ' = ' . $id);
|
||||
|
||||
if (!$this->isStaff)
|
||||
{
|
||||
$query->where($db->quoteName('r.is_internal') . ' = 0');
|
||||
}
|
||||
|
||||
$query->order($db->quoteName('r.created') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$this->ticket->replies = $db->loadObjectList() ?: [];
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas.site
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Site\View\Tickets;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $tickets = [];
|
||||
protected $categories = [];
|
||||
protected $isStaff = false;
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
$this->isStaff = $user->authorise('core.admin')
|
||||
|| $user->authorise('mokowaas.tickets', 'com_mokowaas');
|
||||
|
||||
// Staff see all tickets, customers see their own
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('t.id'),
|
||||
$db->quoteName('t.subject'),
|
||||
$db->quoteName('t.status'),
|
||||
$db->quoteName('t.priority'),
|
||||
$db->quoteName('t.created'),
|
||||
$db->quoteName('t.assigned_to'),
|
||||
$db->quoteName('c.title', 'category_title'),
|
||||
$db->quoteName('u.name', 'created_by_name'),
|
||||
$db->quoteName('a.name', 'assigned_to_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokowaas_tickets', 't'))
|
||||
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to');
|
||||
|
||||
if (!$this->isStaff)
|
||||
{
|
||||
$query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
|
||||
}
|
||||
|
||||
$filterStatus = Factory::getApplication()->getInput()->getString('filter_status', '');
|
||||
|
||||
if ($filterStatus)
|
||||
{
|
||||
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($filterStatus));
|
||||
}
|
||||
|
||||
$query->order($db->quoteName('t.created') . ' DESC')->setLimit(50);
|
||||
$db->setQuery($query);
|
||||
$this->tickets = $db->loadObjectList() ?: [];
|
||||
|
||||
// Categories for new ticket form
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('id'), $db->quoteName('title')])
|
||||
->from($db->quoteName('#__mokowaas_ticket_categories'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$this->categories = $db->loadObjectList() ?: [];
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
$t = $this->ticket;
|
||||
$isStaff = $this->isStaff;
|
||||
$canAssign = $this->canAssign;
|
||||
$token = Session::getFormToken();
|
||||
$userId = Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
$statusLabel = [
|
||||
'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response',
|
||||
'resolved' => 'Resolved', 'closed' => 'Closed',
|
||||
];
|
||||
$statusClass = [
|
||||
'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning',
|
||||
'resolved' => 'success', 'closed' => 'secondary',
|
||||
];
|
||||
?>
|
||||
|
||||
<div class="mokowaas-portal-ticket">
|
||||
<div class="mb-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=tickets'); ?>" class="btn btn-sm btn-outline-secondary">
|
||||
<span class="icon-arrow-left"></span> Back to Tickets
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Main column: conversation -->
|
||||
<div class="col-12 <?php echo $isStaff ? 'col-lg-8' : ''; ?>">
|
||||
|
||||
<!-- Ticket header -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h3 class="mb-1">#<?php echo $t->id; ?> — <?php echo htmlspecialchars($t->subject); ?></h3>
|
||||
<small class="text-muted">
|
||||
<?php echo htmlspecialchars($t->category_title ?? 'General'); ?>
|
||||
· <?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?>
|
||||
· <?php echo ucfirst($t->priority); ?>
|
||||
<?php if ($isStaff): ?>
|
||||
· By: <?php echo htmlspecialchars($t->created_by_name); ?>
|
||||
<?php endif; ?>
|
||||
</small>
|
||||
</div>
|
||||
<span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?> fs-6">
|
||||
<?php echo $statusLabel[$t->status] ?? $t->status; ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Original message -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<strong><?php echo htmlspecialchars($t->created_by_name); ?></strong>
|
||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></small>
|
||||
</div>
|
||||
<div class="card-body"><?php echo nl2br(htmlspecialchars($t->body)); ?></div>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
<?php foreach ($t->replies as $reply): ?>
|
||||
<?php
|
||||
$replyIsStaffUser = ((int) $reply->user_id !== (int) $t->created_by);
|
||||
$isInternal = (int) $reply->is_internal;
|
||||
?>
|
||||
<div class="card mb-3 <?php echo $isInternal ? 'border-warning bg-warning bg-opacity-10' : ($replyIsStaffUser ? 'border-primary' : ''); ?>">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<div>
|
||||
<strong><?php echo htmlspecialchars($reply->user_name ?? 'Support'); ?></strong>
|
||||
<?php if ($replyIsStaffUser): ?><span class="badge bg-primary ms-1">Staff</span><?php endif; ?>
|
||||
<?php if ($isInternal): ?><span class="badge bg-warning text-dark ms-1">Internal Note</span><?php endif; ?>
|
||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $reply->created, 'M d, Y H:i'); ?></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body"><?php echo nl2br(htmlspecialchars($reply->body)); ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Reply form -->
|
||||
<?php if (!\in_array($t->status, ['closed'])): ?>
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h5>Reply</h5>
|
||||
<form id="portalReply">
|
||||
<textarea name="body" class="form-control mb-3" rows="5" required placeholder="Type your reply..."></textarea>
|
||||
<input type="hidden" name="ticket_id" value="<?php echo $t->id; ?>">
|
||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-paper-plane"></span> Send Reply
|
||||
</button>
|
||||
<?php if ($isStaff): ?>
|
||||
<button type="button" class="btn btn-outline-warning" id="btn-internal-note">
|
||||
<span class="icon-eye-slash"></span> Internal Note
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($t->status === 'closed'): ?>
|
||||
<div class="alert alert-secondary mt-4">
|
||||
This ticket is closed. <a href="<?php echo Route::_('index.php?option=com_mokowaas&view=tickets&layout=submit'); ?>">Open a new ticket</a> if you need further help.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Staff sidebar -->
|
||||
<?php if ($isStaff): ?>
|
||||
<div class="col-12 col-lg-4">
|
||||
<!-- Ticket info -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Details</strong></div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-5 text-muted">Status</dt>
|
||||
<dd class="col-7"><span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$t->status] ?? $t->status; ?></span></dd>
|
||||
<dt class="col-5 text-muted">Priority</dt>
|
||||
<dd class="col-7"><?php echo ucfirst($t->priority); ?></dd>
|
||||
<dt class="col-5 text-muted">Category</dt>
|
||||
<dd class="col-7"><?php echo htmlspecialchars($t->category_title ?? '—'); ?></dd>
|
||||
<dt class="col-5 text-muted">Submitted By</dt>
|
||||
<dd class="col-7"><?php echo htmlspecialchars($t->created_by_name); ?><br><small class="text-muted"><?php echo htmlspecialchars($t->created_by_email ?? ''); ?></small></dd>
|
||||
<dt class="col-5 text-muted">Assigned To</dt>
|
||||
<dd class="col-7"><?php echo htmlspecialchars($t->assigned_to_name ?? 'Unassigned'); ?></dd>
|
||||
<dt class="col-5 text-muted">Created</dt>
|
||||
<dd class="col-7"><?php echo HTMLHelper::_('date', $t->created, 'M d H:i'); ?></dd>
|
||||
<dt class="col-5 text-muted">Replies</dt>
|
||||
<dd class="col-7"><?php echo \count($t->replies); ?></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status actions -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Change Status</strong></div>
|
||||
<div class="card-body d-grid gap-2">
|
||||
<?php foreach (['open' => 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting on Customer', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?>
|
||||
<?php if ($s !== $t->status): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-<?php echo $s === 'closed' ? 'danger' : ($s === 'resolved' ? 'success' : 'secondary'); ?> btn-status"
|
||||
data-status="<?php echo $s; ?>">
|
||||
<?php echo $label; ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($canAssign): ?>
|
||||
<!-- Quick assign -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Assign</strong></div>
|
||||
<div class="card-body">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary w-100" id="btn-assign-me">
|
||||
Assign to Me
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var token = '<?php echo $token; ?>';
|
||||
var ticketId = <?php echo $t->id; ?>;
|
||||
|
||||
// Reply
|
||||
var replyForm = document.getElementById('portalReply');
|
||||
if (replyForm) {
|
||||
replyForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
sendReply(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Internal note
|
||||
var internalBtn = document.getElementById('btn-internal-note');
|
||||
if (internalBtn) {
|
||||
internalBtn.addEventListener('click', function() { sendReply(true); });
|
||||
}
|
||||
|
||||
function sendReply(isInternal) {
|
||||
var body = replyForm.querySelector('textarea[name=body]').value.trim();
|
||||
if (!body) return;
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', ticketId);
|
||||
fd.append('body', body);
|
||||
fd.append('is_internal', isInternal ? '1' : '0');
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.submitReply&format=json"); ?>', {
|
||||
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
if (d.success) location.reload();
|
||||
else alert(d.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Status buttons
|
||||
document.querySelectorAll('.btn-status').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', ticketId);
|
||||
fd.append('status', this.dataset.status);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.updateStatus&format=json"); ?>', {
|
||||
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
if (d.success) location.reload();
|
||||
else alert(d.message);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Assign to me
|
||||
var assignBtn = document.getElementById('btn-assign-me');
|
||||
if (assignBtn) {
|
||||
assignBtn.addEventListener('click', function() {
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', ticketId);
|
||||
fd.append('assigned_to', <?php echo $userId; ?>);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.assignTicket&format=json"); ?>', {
|
||||
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
if (d.success) location.reload();
|
||||
else alert(d.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$tickets = $this->tickets;
|
||||
$categories = $this->categories;
|
||||
$isStaff = $this->isStaff;
|
||||
$token = Session::getFormToken();
|
||||
|
||||
$statusLabel = [
|
||||
'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response',
|
||||
'resolved' => 'Resolved', 'closed' => 'Closed',
|
||||
];
|
||||
$statusClass = [
|
||||
'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning',
|
||||
'resolved' => 'success', 'closed' => 'secondary',
|
||||
];
|
||||
?>
|
||||
|
||||
<div class="mokowaas-portal">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><?php echo $isStaff ? 'All Support Tickets' : 'My Support Tickets'; ?></h2>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=tickets&layout=submit'); ?>" class="btn btn-primary">
|
||||
<span class="icon-plus"></span> New Ticket
|
||||
</a>
|
||||
<?php if ($isStaff): ?>
|
||||
<form method="get" class="d-inline">
|
||||
<input type="hidden" name="option" value="com_mokowaas">
|
||||
<input type="hidden" name="view" value="tickets">
|
||||
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
|
||||
<option value="">All Statuses</option>
|
||||
<?php foreach ($statusLabel as $k => $v): ?>
|
||||
<option value="<?php echo $k; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $k ? 'selected' : ''; ?>><?php echo $v; ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (empty($tickets)): ?>
|
||||
<div class="alert alert-info">
|
||||
<span class="icon-info-circle"></span>
|
||||
<?php echo $isStaff ? 'No tickets found.' : 'You haven\'t submitted any support tickets yet.'; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Subject</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Category</th>
|
||||
<?php if ($isStaff): ?><th>Submitted By</th><th>Assigned To</th><?php endif; ?>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($tickets as $t): ?>
|
||||
<tr>
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo htmlspecialchars(mb_substr($t->subject, 0, 60)); ?></a></td>
|
||||
<td><span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$t->status] ?? $t->status; ?></span></td>
|
||||
<td><?php echo ucfirst($t->priority); ?></td>
|
||||
<td><?php echo htmlspecialchars($t->category_title ?? '—'); ?></td>
|
||||
<?php if ($isStaff): ?>
|
||||
<td><?php echo htmlspecialchars($t->created_by_name ?? ''); ?></td>
|
||||
<td><?php echo htmlspecialchars($t->assigned_to_name ?? '<em>Unassigned</em>'); ?></td>
|
||||
<?php endif; ?>
|
||||
<td class="text-nowrap"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -1,204 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Submit a Ticket layout — search KB first, then submit form.
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$categories = $this->categories;
|
||||
$token = Session::getFormToken();
|
||||
$searchUrl = Route::_('index.php?option=com_mokowaas&task=display.searchKb&format=json');
|
||||
$submitUrl = Route::_('index.php?option=com_mokowaas&task=display.submitTicket&format=json');
|
||||
$ticketUrl = Route::_('index.php?option=com_mokowaas&view=ticket&id=');
|
||||
$ticketsUrl = Route::_('index.php?option=com_mokowaas&view=tickets');
|
||||
|
||||
// Check if Smart Search has indexed content
|
||||
$finderEnabled = false;
|
||||
try {
|
||||
$db = \Joomla\CMS\Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__finder_links WHERE published = 1');
|
||||
$finderEnabled = (int) $db->loadResult() > 0;
|
||||
} catch (\Throwable $e) {}
|
||||
?>
|
||||
|
||||
<div class="mokowaas-portal">
|
||||
<h2>Submit a Support Request</h2>
|
||||
|
||||
<?php if ($finderEnabled): ?>
|
||||
<!-- Step 1: Search -->
|
||||
<div id="step-search" class="mb-4">
|
||||
<p class="text-muted">Before submitting, let's see if we already have an answer for you.</p>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<label class="form-label fw-bold" for="kb-search">Describe your issue</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<input type="text" id="kb-search" class="form-control" placeholder="e.g. how do I reset my password?" autofocus>
|
||||
<button type="button" class="btn btn-primary" id="kb-search-btn">
|
||||
<span class="icon-search"></span> Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search results -->
|
||||
<div id="kb-results" class="mt-3 d-none">
|
||||
<h5>Related Articles</h5>
|
||||
<div id="kb-results-list" class="list-group mb-3"></div>
|
||||
<p class="text-muted">Didn't find what you need?</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-outline-primary" id="btn-show-form">
|
||||
<span class="icon-plus"></span> Submit a Ticket Anyway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Step 2: Ticket Form -->
|
||||
<div id="step-form" class="<?php echo $finderEnabled ? 'd-none' : ''; ?>">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">Ticket Details</h5>
|
||||
<form id="submitTicketForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="ticket-subject">Subject <span class="text-danger">*</span></label>
|
||||
<input type="text" id="ticket-subject" name="subject" class="form-control" required placeholder="Brief description of your issue">
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="ticket-category">Category</label>
|
||||
<select id="ticket-category" name="category_id" class="form-select">
|
||||
<option value="">Select a category</option>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="ticket-priority">Priority</label>
|
||||
<select id="ticket-priority" name="priority" class="form-select">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="ticket-body">Description <span class="text-danger">*</span></label>
|
||||
<textarea id="ticket-body" name="body" class="form-control" rows="8" required placeholder="Please describe your issue in detail."></textarea>
|
||||
</div>
|
||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<span class="icon-paper-plane"></span> Submit Ticket
|
||||
</button>
|
||||
<a href="<?php echo $ticketsUrl; ?>" class="btn btn-outline-secondary btn-lg">
|
||||
My Tickets
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var searchInput = document.getElementById('kb-search');
|
||||
var searchBtn = document.getElementById('kb-search-btn');
|
||||
var resultBox = document.getElementById('kb-results');
|
||||
var resultList = document.getElementById('kb-results-list');
|
||||
var showFormBtn = document.getElementById('btn-show-form');
|
||||
var stepSearch = document.getElementById('step-search');
|
||||
var stepForm = document.getElementById('step-form');
|
||||
var subjectField = document.getElementById('ticket-subject');
|
||||
|
||||
// Search
|
||||
function doSearch() {
|
||||
var q = (searchInput ? searchInput.value.trim() : '');
|
||||
if (q.length < 3) return;
|
||||
|
||||
fetch('<?php echo $searchUrl; ?>&q=' + encodeURIComponent(q), {
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
resultList.textContent = '';
|
||||
if (d.results && d.results.length > 0) {
|
||||
d.results.forEach(function(item) {
|
||||
var a = document.createElement('a');
|
||||
a.href = item.url;
|
||||
a.target = '_blank';
|
||||
a.className = 'list-group-item list-group-item-action';
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = item.title;
|
||||
a.appendChild(strong);
|
||||
if (item.description) {
|
||||
a.appendChild(document.createElement('br'));
|
||||
var small = document.createElement('small');
|
||||
small.className = 'text-muted';
|
||||
small.textContent = item.description;
|
||||
a.appendChild(small);
|
||||
}
|
||||
resultList.appendChild(a);
|
||||
});
|
||||
resultBox.classList.remove('d-none');
|
||||
} else {
|
||||
resultBox.classList.add('d-none');
|
||||
}
|
||||
// Always show the "submit anyway" button after search
|
||||
if (showFormBtn) showFormBtn.classList.remove('d-none');
|
||||
});
|
||||
}
|
||||
|
||||
if (searchBtn) searchBtn.addEventListener('click', doSearch);
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); doSearch(); }
|
||||
});
|
||||
}
|
||||
|
||||
// Show form and prefill subject from search query
|
||||
if (showFormBtn) {
|
||||
showFormBtn.addEventListener('click', function() {
|
||||
if (stepSearch) stepSearch.classList.add('d-none');
|
||||
if (stepForm) stepForm.classList.remove('d-none');
|
||||
if (searchInput && subjectField && !subjectField.value) {
|
||||
subjectField.value = searchInput.value;
|
||||
}
|
||||
subjectField.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Submit ticket
|
||||
var form = document.getElementById('submitTicketForm');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var btn = form.querySelector('button[type=submit]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = ' Submitting...';
|
||||
var fd = new FormData(form);
|
||||
fetch('<?php echo $submitUrl; ?>', {
|
||||
method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success && d.id) {
|
||||
window.location.href = '<?php echo $ticketUrl; ?>' + d.id;
|
||||
} else {
|
||||
alert(d.message || 'Failed.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = ' Submit Ticket';
|
||||
}
|
||||
})
|
||||
.catch(function() { alert('Network error.'); btn.disabled = false; });
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.00</version>
|
||||
<description>MOD_MOKOWAAS_CPANEL_DESC</description>
|
||||
<namespace path="src">Moko\Module\MokoWaaSCpanel</namespace>
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.00
|
||||
* PATH: /src/Extension/MokoWaaS.php
|
||||
* NOTE: Handles Joomla system events for rebranding functionality
|
||||
*/
|
||||
@@ -161,7 +161,20 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
*/
|
||||
public function boot(ContainerInterface $container): void
|
||||
{
|
||||
// Session lifetime for trusted IPs is now handled by the firewall plugin
|
||||
$timeout = (int) $this->params->get('admin_session_timeout', 0);
|
||||
|
||||
if ($timeout <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->ipIsTrusted())
|
||||
{
|
||||
// Set both PHP and Joomla session lifetimes before the
|
||||
// session handler runs its expiry check.
|
||||
ini_set('session.gc_maxlifetime', 315360000);
|
||||
Factory::getConfig()->set('lifetime', 525600);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,7 +189,12 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
*/
|
||||
public function onAfterInitialise()
|
||||
{
|
||||
// Site alias handling
|
||||
// Security: HTTPS redirect (runs for all clients)
|
||||
$this->enforceHttps();
|
||||
|
||||
// Site alias handling: offline page and backend redirect.
|
||||
// Must run in onAfterInitialise (not onAfterRoute) so that
|
||||
// Joomla's offline check in doExecute() sees the updated config.
|
||||
$this->handleSiteAlias();
|
||||
|
||||
// MokoWaaS API endpoints (run before routing)
|
||||
@@ -187,15 +205,18 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
$this->handleMokoApi($mokoAction);
|
||||
}
|
||||
|
||||
// Admin-only core controls (branding, emergency access, master user)
|
||||
// NOTE: enforceHttps, enforceDevMode, enforceAdminSessionTimeout,
|
||||
// enforceUploadRestrictions are now in feature plugins
|
||||
// Dev mode: disable caching
|
||||
$this->enforceDevMode();
|
||||
|
||||
// Admin-only WaaS controls
|
||||
if ($this->app->isClient('administrator'))
|
||||
{
|
||||
$this->handleEmergencyAccess();
|
||||
$this->enforceMasterUser();
|
||||
$this->enforceLoginSupportUrls();
|
||||
$this->enforceAtumBranding();
|
||||
$this->enforceAdminSessionTimeout();
|
||||
$this->enforceUploadRestrictions();
|
||||
}
|
||||
|
||||
$this->loadLanguageOverrides();
|
||||
@@ -794,6 +815,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
return $strings;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Event triggered after an extension's config is saved.
|
||||
*
|
||||
@@ -861,7 +883,41 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
// Grafana auto-provisioning
|
||||
$this->handleGrafanaProvisioning($params, $app);
|
||||
|
||||
// NOTE: reset_hits and delete_versions now handled by devtools plugin
|
||||
if ((int) $params->get('reset_hits', 0) === 1)
|
||||
{
|
||||
$count = $this->resetAllHits();
|
||||
$params->set('reset_hits', '0');
|
||||
$changed = true;
|
||||
|
||||
$app->enqueueMessage(
|
||||
sprintf('Reset hit counters on %d articles.', $count),
|
||||
'message'
|
||||
);
|
||||
|
||||
Log::add(
|
||||
sprintf('All article hits reset (%d rows) by MokoWaaS', $count),
|
||||
Log::WARNING,
|
||||
'mokowaas'
|
||||
);
|
||||
}
|
||||
|
||||
if ((int) $params->get('delete_versions', 0) === 1)
|
||||
{
|
||||
$count = $this->deleteAllVersions();
|
||||
$params->set('delete_versions', '0');
|
||||
$changed = true;
|
||||
|
||||
$app->enqueueMessage(
|
||||
sprintf('Deleted %d version history records.', $count),
|
||||
'message'
|
||||
);
|
||||
|
||||
Log::add(
|
||||
sprintf('All content versions purged (%d rows) by MokoWaaS', $count),
|
||||
Log::WARNING,
|
||||
'mokowaas'
|
||||
);
|
||||
}
|
||||
|
||||
// Content Sync: Push Now
|
||||
if ((int) $params->get('sync_push_now', 0) === 1)
|
||||
@@ -921,6 +977,48 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all article hit counters to zero.
|
||||
*
|
||||
* @return int Number of rows affected
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function resetAllHits()
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__content'))
|
||||
->set($db->quoteName('hits') . ' = 0')
|
||||
->where($db->quoteName('hits') . ' > 0')
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
return $db->getAffectedRows();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all content version history records.
|
||||
*
|
||||
* @return int Number of rows deleted
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function deleteAllVersions()
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__history'))
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
return $db->getAffectedRows();
|
||||
}
|
||||
|
||||
/**
|
||||
* Event triggered after the route has been determined.
|
||||
*
|
||||
@@ -938,8 +1036,8 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
return;
|
||||
}
|
||||
|
||||
// NOTE: warnMissingLicenseKey and enforceAdminRestrictions
|
||||
// are now handled by feature plugins (deferred / tenant)
|
||||
$this->warnMissingLicenseKey();
|
||||
$this->enforceAdminRestrictions();
|
||||
$this->protectPlugin();
|
||||
}
|
||||
|
||||
@@ -1411,7 +1509,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
$db->quote('mokowaas_firewall'),
|
||||
$db->quote('mokowaas_tenant'),
|
||||
$db->quote('mokowaas_devtools'),
|
||||
$db->quote('mokowaas_offline'),
|
||||
$db->quote('mokowaas_monitor'),
|
||||
$db->quote('mod_mokowaas_cpanel'),
|
||||
];
|
||||
|
||||
@@ -1455,8 +1553,107 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
}
|
||||
}
|
||||
|
||||
// onPreprocessMenuItems — REMOVED, now in plg_system_mokowaas_tenant
|
||||
// onUserBeforeSave — REMOVED, now in plg_system_mokowaas_firewall
|
||||
/**
|
||||
* Filter admin menu items for non-master users.
|
||||
*
|
||||
* @param string $context Menu context
|
||||
* @param array &$items Menu items (by reference)
|
||||
* @param mixed $params Module params
|
||||
* @param mixed $enabled Whether module is enabled
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
public function onPreprocessMenuItems($context, &$items, $params, $enabled)
|
||||
{
|
||||
if (!$this->app->isClient('administrator'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isMasterUser())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$hidden = $this->getHiddenMenuComponents();
|
||||
|
||||
if (empty($hidden))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($items as $key => $item)
|
||||
{
|
||||
foreach ($hidden as $component)
|
||||
{
|
||||
if (isset($item->link)
|
||||
&& strpos($item->link, 'option=' . $component) !== false)
|
||||
{
|
||||
unset($items[$key]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce password policy before user save.
|
||||
*
|
||||
* @param array $oldUser Existing user data
|
||||
* @param boolean $isNew Whether this is a new user
|
||||
* @param array $newUser New user data being saved
|
||||
*
|
||||
* @return boolean True to allow save
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
public function onUserBeforeSave($oldUser, $isNew, $newUser)
|
||||
{
|
||||
if (empty($newUser['password_clear']))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
$password = $newUser['password_clear'];
|
||||
$errors = [];
|
||||
|
||||
$minLen = (int) $this->params->get('password_min_length', 12);
|
||||
|
||||
if (strlen($password) < $minLen)
|
||||
{
|
||||
$errors[] = sprintf(
|
||||
'Password must be at least %d characters.', $minLen
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->params->get('password_require_uppercase', 1)
|
||||
&& !preg_match('/[A-Z]/', $password))
|
||||
{
|
||||
$errors[] = 'Password must contain an uppercase letter.';
|
||||
}
|
||||
|
||||
if ($this->params->get('password_require_number', 1)
|
||||
&& !preg_match('/\d/', $password))
|
||||
{
|
||||
$errors[] = 'Password must contain a number.';
|
||||
}
|
||||
|
||||
if ($this->params->get('password_require_special', 1)
|
||||
&& !preg_match('/[^A-Za-z0-9]/', $password))
|
||||
{
|
||||
$errors[] = 'Password must contain a special character.';
|
||||
}
|
||||
|
||||
if (!empty($errors))
|
||||
{
|
||||
throw new \RuntimeException(implode(' ', $errors));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Diagnostics / Health Endpoint (called from onAfterInitialise)
|
||||
@@ -4223,6 +4420,130 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
// License key check (called from onAfterRoute)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Show a persistent admin warning if no license key is set on the
|
||||
* MokoWaaS update site.
|
||||
*
|
||||
* Checks the extra_query column in #__update_sites for a dlid value.
|
||||
* Also validates the key against MokoGitea on a heartbeat interval
|
||||
* (once per day) and warns if the key is invalid or expired.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.31.00
|
||||
*/
|
||||
protected function warnMissingLicenseKey(): void
|
||||
{
|
||||
// Only show to master users
|
||||
if (!$this->isMasterUser())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Only warn once per session
|
||||
$session = Factory::getSession();
|
||||
|
||||
if ($session->get('mokowaas.license_warned', false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$session->set('mokowaas.license_warned', true);
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('extra_query'))
|
||||
->from($db->quoteName('#__update_sites'))
|
||||
->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%')
|
||||
. ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')')
|
||||
->setLimit(1);
|
||||
$db->setQuery($query);
|
||||
$extraQuery = (string) $db->loadResult();
|
||||
|
||||
if (empty($extraQuery) || strpos($extraQuery, 'dlid=') === false)
|
||||
{
|
||||
$this->app->enqueueMessage(
|
||||
'<strong>Moko Consulting License Key Required</strong> — '
|
||||
. 'No download key is configured. Updates will not be available until a valid license key is entered. '
|
||||
. 'Go to <a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a> '
|
||||
. 'and enter your license key in the Download Key field for the MokoWaaS update site.',
|
||||
'warning'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the key value from extra_query
|
||||
parse_str($extraQuery, $parsed);
|
||||
$licenseKey = $parsed['dlid'] ?? '';
|
||||
|
||||
if (empty($licenseKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Heartbeat validation — check once per day
|
||||
$session = Factory::getSession();
|
||||
$lastCheck = (int) $session->get('mokowaas.license_check', 0);
|
||||
$now = time();
|
||||
|
||||
if (($now - $lastCheck) < 86400)
|
||||
{
|
||||
// Show cached warning if key was invalid last check
|
||||
if ($session->get('mokowaas.license_invalid', false))
|
||||
{
|
||||
$this->app->enqueueMessage(
|
||||
'<strong>Moko Consulting License Key Invalid</strong> — '
|
||||
. 'Your license key could not be validated. Please verify your key in '
|
||||
. '<a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a>.',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate against MokoGitea
|
||||
$session->set('mokowaas.license_check', $now);
|
||||
|
||||
$validateUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml'
|
||||
. '?dlid=' . urlencode($licenseKey)
|
||||
. '&domain=' . urlencode(Uri::root());
|
||||
|
||||
$ch = curl_init($validateUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
// Empty <updates></updates> or non-200 means invalid key
|
||||
$isValid = ($httpCode === 200 && $response && strpos($response, '<update>') !== false);
|
||||
|
||||
$session->set('mokowaas.license_invalid', !$isValid);
|
||||
|
||||
if (!$isValid)
|
||||
{
|
||||
$this->app->enqueueMessage(
|
||||
'<strong>Moko Consulting License Key Invalid</strong> — '
|
||||
. 'Your license key could not be validated. Updates will not be available. '
|
||||
. 'Please verify your key in '
|
||||
. '<a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a>.',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent — license check is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -4358,6 +4679,110 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
/**
|
||||
* Enforce development mode settings.
|
||||
*
|
||||
* When dev mode is ON:
|
||||
* - Disable Joomla caching
|
||||
* - Enable Joomla debug mode (Global Config)
|
||||
* - Enable MokoOnyx template debug
|
||||
* - Disable article hit recording
|
||||
*
|
||||
* When dev mode is OFF (and was previously on):
|
||||
* - Reset all content version history
|
||||
* - Reset article published dates to now
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.15
|
||||
*/
|
||||
protected function enforceDevMode()
|
||||
{
|
||||
if (!$this->params->get('dev_mode', 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable caching
|
||||
$config = Factory::getConfig();
|
||||
$config->set('caching', 0);
|
||||
|
||||
// Enable Joomla debug
|
||||
$config->set('debug', 1);
|
||||
|
||||
// Enable MokoOnyx template debug
|
||||
$this->setTemplateParam('mokoonyx', 'debug', 1);
|
||||
|
||||
// Show offline page on primary domain only — site aliases
|
||||
// and dev.* subdomains bypass offline mode for development
|
||||
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
$primaryDomain = $this->params->get('primary_domain', '');
|
||||
|
||||
if (!empty($primaryDomain) && $currentHost === $primaryDomain)
|
||||
{
|
||||
$config->set('offline', 1);
|
||||
}
|
||||
|
||||
// Suppress hit recording
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__content'))
|
||||
->set($db->quoteName('hits') . ' = 0')
|
||||
->where($db->quoteName('hits') . ' > 0')
|
||||
)->execute();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions to run when dev mode is turned off.
|
||||
*
|
||||
* Resets content versions and hits, disables debug.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.31.00
|
||||
*/
|
||||
protected function onDevModeDisabled(): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Delete all content version history
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)->delete($db->quoteName('#__history'))
|
||||
)->execute();
|
||||
|
||||
// Reset hits
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__content'))
|
||||
->set($db->quoteName('hits') . ' = 0')
|
||||
)->execute();
|
||||
|
||||
// Disable debug
|
||||
$this->setTemplateParam('mokoonyx', 'debug', 0);
|
||||
|
||||
// Take site back online
|
||||
Factory::getConfig()->set('offline', 0);
|
||||
|
||||
$this->app->enqueueMessage(
|
||||
'Development mode disabled — versions cleared, hits reset, debug off, site online.',
|
||||
'message'
|
||||
);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Dev mode cleanup failed: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a parameter on a template style.
|
||||
@@ -4405,6 +4830,194 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
}
|
||||
}
|
||||
|
||||
protected function enforceHttps()
|
||||
{
|
||||
if (!$this->params->get('force_https', 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->app->isClient('cli'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$isHttps = (!empty($_SERVER['HTTPS'])
|
||||
&& $_SERVER['HTTPS'] !== 'off')
|
||||
|| ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
|
||||
|
||||
if (!$isHttps)
|
||||
{
|
||||
$this->app->redirect(
|
||||
'https://' . $_SERVER['HTTP_HOST']
|
||||
. $_SERVER['REQUEST_URI'], 301
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce admin session idle timeout.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function enforceAdminSessionTimeout()
|
||||
{
|
||||
$timeout = (int) $this->params->get('admin_session_timeout', 0);
|
||||
|
||||
if ($timeout <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't timeout the master user
|
||||
if ($this->isMasterUser())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Trusted IPs — session lifetime already extended in boot()
|
||||
if ($this->ipIsTrusted())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$session = Factory::getSession();
|
||||
$lastHit = $session->get('mokowaas.last_activity', 0);
|
||||
$now = time();
|
||||
|
||||
if ($lastHit > 0 && ($now - $lastHit) > ($timeout * 60))
|
||||
{
|
||||
$this->app->logout();
|
||||
$this->app->redirect(
|
||||
Route::_('index.php', false)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$session->set('mokowaas.last_activity', $now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the current request IP matches any trusted IP entry.
|
||||
*
|
||||
* Supports exact IPs, CIDR notation (e.g. 10.0.0.0/8), and
|
||||
* wildcard patterns (e.g. 192.168.1.*).
|
||||
*
|
||||
* @return bool True if the current IP is in the trusted list.
|
||||
*
|
||||
* @since 02.11.00
|
||||
*/
|
||||
protected function ipIsTrusted(): bool
|
||||
{
|
||||
$entries = $this->params->get('trusted_ips', '');
|
||||
|
||||
if (empty($entries))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Subform stores as JSON string or array
|
||||
if (\is_string($entries))
|
||||
{
|
||||
$entries = json_decode($entries, true);
|
||||
}
|
||||
|
||||
if (!\is_array($entries))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$ip = $this->app
|
||||
? $this->app->input->server->getString('REMOTE_ADDR', '')
|
||||
: ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
$ipLong = ip2long($ip);
|
||||
|
||||
if ($ipLong === false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($entries as $entry)
|
||||
{
|
||||
if (empty($entry['enabled']) || empty($entry['ip']))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$range = trim($entry['ip']);
|
||||
|
||||
// Wildcard: 192.168.1.*
|
||||
if (str_contains($range, '*'))
|
||||
{
|
||||
$pattern = '/^' . str_replace(['.', '*'], ['\\.', '\\d+'], $range) . '$/';
|
||||
|
||||
if (preg_match($pattern, $ip))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// CIDR: 10.0.0.0/8
|
||||
if (str_contains($range, '/'))
|
||||
{
|
||||
[$subnet, $bits] = explode('/', $range, 2);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$mask = -1 << (32 - (int) $bits);
|
||||
|
||||
if ($subnetLong !== false && ($ipLong & $mask) === ($subnetLong & $mask))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if ($ip === $range)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Override Joomla upload restrictions at runtime.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function enforceUploadRestrictions()
|
||||
{
|
||||
$types = $this->params->get('upload_allowed_types', '');
|
||||
$maxMb = (int) $this->params->get('upload_max_size_mb', 0);
|
||||
|
||||
if (empty($types) && $maxMb <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$config = $this->app->getConfig();
|
||||
|
||||
if (!empty($types))
|
||||
{
|
||||
$config->set('upload_extensions', $types);
|
||||
}
|
||||
|
||||
if ($maxMb > 0)
|
||||
{
|
||||
$config->set('upload_maxsize', $maxMb);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce login support module URLs on admin requests.
|
||||
*
|
||||
@@ -4473,6 +5086,121 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
// Tenant Restrictions (called from onAfterRoute)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check admin routes against restriction rules and redirect if blocked.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function enforceAdminRestrictions()
|
||||
{
|
||||
// Master user bypasses ALL restrictions
|
||||
if ($this->isMasterUser())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$input = $this->app->input;
|
||||
$option = $input->get('option', '');
|
||||
$view = $input->get('view', '');
|
||||
$task = $input->get('task', '');
|
||||
|
||||
// Disable install-from-URL for non-master users
|
||||
if ($this->params->get('disable_install_url', 1)
|
||||
&& $option === 'com_installer'
|
||||
&& stripos($task, 'install') !== false
|
||||
&& $input->get('installtype') === 'url')
|
||||
{
|
||||
$this->blockAccess('Install from URL is disabled.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$blocked = [];
|
||||
|
||||
if ($this->params->get('restrict_installer', 1))
|
||||
{
|
||||
// Allow the update view by default so tenants can update extensions
|
||||
$allowUpdates = (int) $this->params->get('allow_extension_updates', 1);
|
||||
|
||||
if ($allowUpdates && $option === 'com_installer'
|
||||
&& \in_array($view, ['update', 'updatesites'], true))
|
||||
{
|
||||
// Do not block — update views are permitted
|
||||
}
|
||||
elseif ($option === 'com_installer')
|
||||
{
|
||||
$this->blockAccess('Access restricted.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->params->get('hide_sysinfo', 1))
|
||||
{
|
||||
$blocked[] = [
|
||||
'option' => 'com_admin',
|
||||
'view' => 'sysinfo',
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->params->get('restrict_global_config', 1))
|
||||
{
|
||||
$blocked[] = [
|
||||
'option' => 'com_config',
|
||||
'view' => 'application',
|
||||
];
|
||||
// Also block empty view (default landing = global config)
|
||||
if ($option === 'com_config' && $view === '')
|
||||
{
|
||||
$this->blockAccess('Access restricted.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->params->get('restrict_template_editing', 1))
|
||||
{
|
||||
$blocked[] = [
|
||||
'option' => 'com_templates',
|
||||
'view' => 'template',
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($blocked as $rule)
|
||||
{
|
||||
if ($option !== $rule['option'])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($rule['view']) && $view !== $rule['view'])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->blockAccess('Access restricted.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to admin dashboard with an error message.
|
||||
*
|
||||
* @param string $message Error message to display
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function blockAccess($message)
|
||||
{
|
||||
$this->app->enqueueMessage($message, 'error');
|
||||
$this->app->redirect(Route::_('index.php', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the current user is the master WaaS user.
|
||||
*
|
||||
@@ -4524,6 +5252,38 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
return $this->masterNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the list of components to hide from admin menu.
|
||||
*
|
||||
* Combines explicit hidden_menu_items config with components that
|
||||
* are implicitly blocked by other restriction toggles.
|
||||
*
|
||||
* @return array Component option strings
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function getHiddenMenuComponents()
|
||||
{
|
||||
$hidden = array_filter(array_map(
|
||||
'trim',
|
||||
explode("\n", $this->params->get('hidden_menu_items', ''))
|
||||
));
|
||||
|
||||
// Auto-hide components that are restricted (keep visible when updates are allowed)
|
||||
if ($this->params->get('restrict_installer', 1)
|
||||
&& !$this->params->get('allow_extension_updates', 1))
|
||||
{
|
||||
$hidden[] = 'com_installer';
|
||||
}
|
||||
|
||||
if ($this->params->get('hide_sysinfo', 1))
|
||||
{
|
||||
$hidden[] = 'com_admin';
|
||||
}
|
||||
|
||||
return array_unique($hidden);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Atum Template Branding (called from onAfterInitialise)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.00
|
||||
* PATH: /src/Field/AllowedIpsField.php
|
||||
* BRIEF: Custom form field that displays the current IP whitelist
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.00
|
||||
* PATH: /src/Field/CopyableTokenField.php
|
||||
* BRIEF: Read-only token field with a copy-to-clipboard button
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.00
|
||||
* PATH: /src/Field/CurrentIpField.php
|
||||
* BRIEF: Read-only field that displays the current user's IP address
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.00
|
||||
* PATH: /src/Field/DemoTaskInfoField.php
|
||||
* BRIEF: Read-only field showing scheduled task info with link to manage it
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.00
|
||||
* PATH: /src/Field/NextResetField.php
|
||||
* BRIEF: Read-only field showing next reset time from Joomla scheduled task
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.00
|
||||
* PATH: /src/Field/SnapshotTablesField.php
|
||||
* BRIEF: Multi-select list field that loads DB tables with sensible defaults
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
* PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.00
|
||||
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
* PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncService.php
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.00
|
||||
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
* PATH: /src/packages/plg_system_mokowaas/Service/DemoResetService.php
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.00
|
||||
* BRIEF: Content-only snapshot/restore for demo site reset
|
||||
*/
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.00</version>
|
||||
<description>This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform.</description>
|
||||
<namespace path=".">Moko\Plugin\System\MokoWaaS</namespace>
|
||||
<scriptfile>script.php</scriptfile>
|
||||
@@ -76,9 +76,7 @@
|
||||
<fields name="params"
|
||||
addfieldprefix="Moko\Plugin\System\MokoWaaS\Field"
|
||||
>
|
||||
<fieldset name="basic"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_CORE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_CORE_DESC">
|
||||
<fieldset name="basic">
|
||||
<field
|
||||
name="health_api_token"
|
||||
type="CopyableToken"
|
||||
@@ -88,6 +86,87 @@
|
||||
filter="raw"
|
||||
readonly="true"
|
||||
/>
|
||||
<field name="dev_mode" type="radio" default="0"
|
||||
label="PLG_SYSTEM_MOKOWAAS_DEV_MODE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_DEV_MODE_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="reset_hits"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESET_HITS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESET_HITS_DESC"
|
||||
default="0"
|
||||
class="btn-group btn-group-yesno"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="delete_versions"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOWAAS_DELETE_VERSIONS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_DELETE_VERSIONS_DESC"
|
||||
default="0"
|
||||
class="btn-group btn-group-yesno"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
<fieldset name="tenant_restrictions"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_DESC"
|
||||
>
|
||||
<field name="restrict_installer" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESTRICT_INSTALLER_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESTRICT_INSTALLER_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="allow_extension_updates" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_ALLOW_UPDATES_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_ALLOW_UPDATES_DESC"
|
||||
class="btn-group btn-group-yesno"
|
||||
showon="restrict_installer:1">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="hide_sysinfo" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_HIDE_SYSINFO_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_HIDE_SYSINFO_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="restrict_global_config" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESTRICT_CONFIG_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESTRICT_CONFIG_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="restrict_template_editing" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESTRICT_TEMPLATE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESTRICT_TEMPLATE_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="disable_install_url" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="hidden_menu_items" type="textarea"
|
||||
label="PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_DESC"
|
||||
rows="5" filter="raw" />
|
||||
</fieldset>
|
||||
<fieldset name="demo_mode"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_LABEL"
|
||||
@@ -119,11 +198,62 @@
|
||||
type="AllowedIps"
|
||||
label=""
|
||||
/>
|
||||
<field name="force_https" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="admin_session_timeout" type="number"
|
||||
label="PLG_SYSTEM_MOKOWAAS_SESSION_TIMEOUT_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_SESSION_TIMEOUT_DESC"
|
||||
default="60" hint="Minutes (0 = Joomla default)" />
|
||||
<field
|
||||
name="current_ip_display"
|
||||
type="CurrentIp"
|
||||
label=""
|
||||
/>
|
||||
<field
|
||||
name="trusted_ips"
|
||||
type="subform"
|
||||
label="PLG_SYSTEM_MOKOWAAS_TRUSTED_IPS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_TRUSTED_IPS_DESC"
|
||||
formsource="plugins/system/mokowaas/forms/trusted_ip_entry.xml"
|
||||
multiple="true"
|
||||
layout="joomla.form.field.subform.repeatable-table"
|
||||
groupByFieldset="false"
|
||||
buttons="add,remove,move"
|
||||
/>
|
||||
<field name="password_min_length" type="number" default="12"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_LENGTH_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_PASSWORD_LENGTH_DESC" />
|
||||
<field name="password_require_uppercase" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_UPPER_LABEL"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="password_require_number" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_NUMBER_LABEL"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="password_require_special" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_SPECIAL_LABEL"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="upload_allowed_types" type="text"
|
||||
label="PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_DESC"
|
||||
default="jpg,jpeg,png,gif,webp,svg,pdf,doc,docx,xls,xlsx" />
|
||||
<field name="upload_max_size_mb" type="number"
|
||||
label="PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_DESC"
|
||||
default="100" />
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.00
|
||||
* PATH: /src/script.php
|
||||
* BRIEF: Installation script for MokoWaaS plugin
|
||||
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.00
|
||||
* PATH: /src/services/provider.php
|
||||
* BRIEF: Service provider for dependency injection in Joomla 5.x
|
||||
* NOTE: Registers the plugin with Joomla's DI container
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.00</version>
|
||||
<description>PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoWaaSDevTools</namespace>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.00</version>
|
||||
<description>PLG_SYSTEM_MOKOWAAS_FIREWALL_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoWaaSFirewall</namespace>
|
||||
|
||||
|
||||
@@ -10,14 +10,12 @@ namespace Moko\Plugin\System\MokoWaaSFirewall\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Extension\BootableExtensionInterface;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
use Moko\Plugin\System\MokoWaaS\Helper\MokoWaaSHelper;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
/**
|
||||
* MokoWaaS Firewall Plugin
|
||||
@@ -27,29 +25,10 @@ use Psr\Container\ContainerInterface;
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
class Firewall extends CMSPlugin implements SubscriberInterface, BootableExtensionInterface
|
||||
class Firewall extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
protected $autoloadLanguage = true;
|
||||
|
||||
/**
|
||||
* Extend session lifetime for trusted IPs before Joomla creates the session.
|
||||
*/
|
||||
public function boot(ContainerInterface $container): void
|
||||
{
|
||||
$timeout = (int) $this->params->get('admin_session_timeout', 0);
|
||||
|
||||
if ($timeout <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->ipIsTrusted())
|
||||
{
|
||||
ini_set('session.gc_maxlifetime', 315360000);
|
||||
Factory::getConfig()->set('lifetime', 525600);
|
||||
}
|
||||
}
|
||||
|
||||
private const BLOCKED_FILES = [
|
||||
'htaccess.txt', 'web.config.txt', 'configuration.php-dist',
|
||||
'README.txt', 'LICENSE.txt', 'joomla.xml', 'robots.txt.dist',
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.00</version>
|
||||
<description>PLG_SYSTEM_MOKOWAAS_MONITOR_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoWaaSMonitor</namespace>
|
||||
|
||||
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
; MokoWaaS Terms of Service Plugin
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE="System - MokoWaaS Offline Bypass"
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC="Keep selected pages (Terms of Service, Privacy Policy, etc.) accessible when the site is in offline mode."
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_FIELDSET_BASIC="Offline-Accessible Pages"
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_LABEL="Menu Items to Keep Online"
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_DESC="Select menu items that remain accessible during offline mode. Hold Ctrl/Cmd for multiple."
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_LABEL="Include Child Menu Items"
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_DESC="Also allow access to child pages under the selected items."
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_SEF_WARNING="SEF URLs are disabled - path matching requires SEF. Itemid fallback is active."
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
; MokoWaaS Terms of Service Plugin - System strings
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE="System - MokoWaaS Offline Bypass"
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC="Keep selected pages (Terms of Service, Privacy Policy, etc.) accessible when the site is in offline mode."
|
||||
@@ -1,44 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoWaaS Offline Bypass</name>
|
||||
<element>mokowaas_offline</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.32.38</version>
|
||||
<description>PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoWaaSOffline</namespace>
|
||||
|
||||
<files>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/plg_system_mokowaas_offline.ini</language>
|
||||
<language tag="en-GB">en-GB/plg_system_mokowaas_offline.sys.ini</language>
|
||||
</languages>
|
||||
|
||||
<config>
|
||||
<fields name="params" addfieldprefix="Moko\Plugin\System\MokoWaaSOffline\Field">
|
||||
<fieldset name="basic" label="PLG_SYSTEM_MOKOWAAS_OFFLINE_FIELDSET_BASIC">
|
||||
<field name="tos_slug" type="menuslug"
|
||||
label="PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_DESC"
|
||||
multiple="true" />
|
||||
|
||||
<field name="include_children" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage plg_system_mokowaas_offline
|
||||
* @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\MokoWaaSOffline\Extension\Tos;
|
||||
|
||||
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 Tos($dispatcher, (array) PluginHelper::getPlugin('system', 'mokowaas_offline'));
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,172 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage plg_system_mokowaas_offline
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoWaaSOffline\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
/**
|
||||
* MokoWaaS Terms of Service Plugin
|
||||
*
|
||||
* Allows configured menu items (Terms of Service, Privacy Policy, etc.)
|
||||
* to remain accessible when the site is in offline mode.
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
final class Tos extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
protected $autoloadLanguage = true;
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'onAfterRoute' => 'onAfterRoute',
|
||||
];
|
||||
}
|
||||
|
||||
public function onAfterRoute(): void
|
||||
{
|
||||
$app = $this->getApplication();
|
||||
|
||||
if (!$app->isClient('site'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$config = $app->getConfig();
|
||||
|
||||
if (!$config->get('offline'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$slugs = $this->params->get('tos_slug', []);
|
||||
|
||||
if (\is_string($slugs))
|
||||
{
|
||||
$slugs = array_filter([trim($slugs)]);
|
||||
}
|
||||
else
|
||||
{
|
||||
$slugs = (array) $slugs;
|
||||
}
|
||||
|
||||
if (empty($slugs))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$includeChildren = (int) $this->params->get('include_children', 1);
|
||||
|
||||
if ($this->matchByPath($slugs, $config, $app, $includeChildren))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$this->matchByItemId($slugs, $config, $app, $includeChildren);
|
||||
}
|
||||
|
||||
private function matchByPath(array $slugs, $config, $app, int $includeChildren = 1): bool
|
||||
{
|
||||
$uri = Uri::getInstance();
|
||||
$path = urldecode(trim($uri->getPath(), '/'));
|
||||
|
||||
$base = trim(Uri::base(true), '/');
|
||||
|
||||
if (!empty($base) && strpos($path, $base) === 0)
|
||||
{
|
||||
$path = trim(substr($path, \strlen($base)), '/');
|
||||
}
|
||||
|
||||
if (empty($path) || $path === 'index.php')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($slugs as $slug)
|
||||
{
|
||||
$slug = trim((string) $slug);
|
||||
|
||||
if (empty($slug))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($path === $slug || ($includeChildren && strpos($path, $slug . '/') === 0))
|
||||
{
|
||||
$this->bypassOffline($config, $app);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function matchByItemId(array $slugs, $config, $app, int $includeChildren = 1): bool
|
||||
{
|
||||
$itemId = (int) $app->getInput()->getInt('Itemid', 0);
|
||||
|
||||
if (!$itemId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('path'))
|
||||
->from($db->quoteName('#__menu'))
|
||||
->where($db->quoteName('id') . ' = ' . $itemId)
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where($db->quoteName('client_id') . ' = 0');
|
||||
$db->setQuery($query);
|
||||
$menuPath = trim((string) $db->loadResult(), '/');
|
||||
|
||||
if (empty($menuPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($slugs as $slug)
|
||||
{
|
||||
$slug = trim((string) $slug);
|
||||
|
||||
if (empty($slug))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($menuPath === $slug || ($includeChildren && strpos($menuPath, $slug . '/') === 0))
|
||||
{
|
||||
$this->bypassOffline($config, $app);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function bypassOffline($config, $app): void
|
||||
{
|
||||
$config->set('offline', 0);
|
||||
$app->getInput()->set('tmpl', 'component');
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage plg_system_mokowaas_offline
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoWaaSOffline\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Form\Field\ListField;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
class MenuslugField extends ListField
|
||||
{
|
||||
protected $type = 'Menuslug';
|
||||
|
||||
protected function getOptions()
|
||||
{
|
||||
$options = parent::getOptions();
|
||||
|
||||
try
|
||||
{
|
||||
$sef = Factory::getApplication()->get('sef', true);
|
||||
|
||||
if (!$sef)
|
||||
{
|
||||
$options[] = (object) [
|
||||
'value' => '',
|
||||
'text' => Text::_('PLG_SYSTEM_MOKOWAAS_OFFLINE_SEF_WARNING'),
|
||||
'disabled' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['path', 'alias', 'title', 'menutype']))
|
||||
->from($db->quoteName('#__menu'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where($db->quoteName('client_id') . ' = 0')
|
||||
->where($db->quoteName('alias') . ' != ' . $db->quote(''))
|
||||
->order($db->quoteName('menutype') . ', ' . $db->quoteName('title'));
|
||||
$db->setQuery($query);
|
||||
$menuItems = $db->loadObjectList();
|
||||
|
||||
$lastMenuType = '';
|
||||
|
||||
foreach ($menuItems ?: [] as $item)
|
||||
{
|
||||
if ($item->menutype !== $lastMenuType)
|
||||
{
|
||||
if ($lastMenuType !== '')
|
||||
{
|
||||
$options[] = (object) ['value' => '', 'text' => '──────────────', 'disabled' => true];
|
||||
}
|
||||
|
||||
$lastMenuType = $item->menutype;
|
||||
}
|
||||
|
||||
$label = $item->title !== '' ? $item->title : ucwords(str_replace(['-', '_'], ' ', $item->alias));
|
||||
$options[] = (object) ['value' => $item->path, 'text' => $label . ' (/' . $item->path . ')'];
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.00</version>
|
||||
<description>PLG_SYSTEM_MOKOWAAS_TENANT_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoWaaSTenant</namespace>
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
PLG_TASK_MOKOWAAS_TICKETS="Task - MokoWaaS Ticket Automation"
|
||||
PLG_TASK_MOKOWAAS_TICKETS_DESC="Runs scheduled helpdesk automation rules."
|
||||
PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION_TITLE="MokoWaaS: Ticket Automation"
|
||||
PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION_DESC="Runs time-based automation rules against open tickets (auto-close, SLA escalation, etc.)."
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
PLG_TASK_MOKOWAAS_TICKETS="Task - MokoWaaS Ticket Automation"
|
||||
PLG_TASK_MOKOWAAS_TICKETS_DESC="Runs scheduled helpdesk automation rules — auto-close, SLA escalation, and time-based actions."
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="plugin" group="task" method="upgrade">
|
||||
<name>Task - MokoWaaS Ticket Automation</name>
|
||||
<element>mokowaas_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.32.38</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\MokoWaaSTickets</namespace>
|
||||
|
||||
<files>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/plg_task_mokowaas_tickets.ini</language>
|
||||
<language tag="en-GB">en-GB/plg_task_mokowaas_tickets.sys.ini</language>
|
||||
</languages>
|
||||
</extension>
|
||||
@@ -1,27 +0,0 @@
|
||||
<?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\MokoWaaSTickets\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', 'mokowaas_tickets'));
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage plg_task_mokowaas_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\MokoWaaSTickets\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
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\MokoWaaS\Administrator\Model\TicketsModel;
|
||||
|
||||
class TicketAutomation extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
use TaskPluginTrait;
|
||||
|
||||
protected const TASKS_MAP = [
|
||||
'mokowaas.ticket.automation' => [
|
||||
'langConstPrefix' => 'PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION',
|
||||
'method' => 'runAutomation',
|
||||
],
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.38</version>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.00</version>
|
||||
<version>02.34.00</version>
|
||||
<description>PLG_TASK_MOKOWAASDEMO_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\Task\MokoWaaSDemo</namespace>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.00</version>
|
||||
<description>PLG_TASK_MOKOWAASSYNC_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\Task\MokoWaaSSync</namespace>
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.38</version>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.00</version>
|
||||
<version>02.34.00</version>
|
||||
<description>Joomla Web Services API routes for MokoWaaS site management — health checks, cache, updates, backups, and site info.</description>
|
||||
<namespace path="src">Moko\Plugin\WebServices\MokoWaaS</namespace>
|
||||
<files>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.38</version>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.00</version>
|
||||
<version>02.34.00</version>
|
||||
<description>Joomla Web Services API routes for Perfect Publisher (com_autotweet) — channels, posts, requests, rules, and feeds.</description>
|
||||
<namespace path="src">Moko\Plugin\WebServices\PerfectPublisher</namespace>
|
||||
<files>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
* PATH: /src/packages/plg_webservices_perfectpublisher/services/provider.php
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.00
|
||||
* BRIEF: DI service provider for Perfect Publisher Web Services plugin
|
||||
*/
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
* PATH: /src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.00
|
||||
* BRIEF: Web Services API plugin for Perfect Publisher (com_autotweet)
|
||||
*/
|
||||
|
||||
|
||||
Submodule src/packages/tpl_mokoonyx updated: f3897495ad...16a7090f29
@@ -2,7 +2,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoWaaS</name>
|
||||
<packagename>mokowaas</packagename>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -17,18 +17,19 @@
|
||||
<file type="plugin" id="plg_system_mokowaas_firewall" group="system">plg_system_mokowaas_firewall.zip</file>
|
||||
<file type="plugin" id="plg_system_mokowaas_tenant" group="system">plg_system_mokowaas_tenant.zip</file>
|
||||
<file type="plugin" id="plg_system_mokowaas_devtools" group="system">plg_system_mokowaas_devtools.zip</file>
|
||||
<file type="plugin" id="plg_system_mokowaas_offline" group="system">plg_system_mokowaas_offline.zip</file>
|
||||
<file type="plugin" id="plg_system_mokowaas_monitor" group="system">plg_system_mokowaas_monitor.zip</file>
|
||||
<file type="component" id="com_mokowaas">com_mokowaas.zip</file>
|
||||
<file type="module" id="mod_mokowaas_cpanel" client="administrator">mod_mokowaas_cpanel.zip</file>
|
||||
<file type="plugin" id="plg_webservices_mokowaas" group="webservices">plg_webservices_mokowaas.zip</file>
|
||||
<file type="plugin" id="plg_webservices_perfectpublisher" group="webservices">plg_webservices_perfectpublisher.zip</file>
|
||||
<file type="plugin" id="plg_task_mokowaasdemo" group="task">plg_task_mokowaasdemo.zip</file>
|
||||
<file type="plugin" id="plg_task_mokowaassync" group="task">plg_task_mokowaassync.zip</file>
|
||||
<file type="plugin" id="plg_task_mokowaas_tickets" group="task">plg_task_mokowaas_tickets.zip</file>
|
||||
<file type="template" id="mokoonyx" client="site">tpl_mokoonyx.zip</file>
|
||||
</files>
|
||||
|
||||
<updateservers>
|
||||
<server type="extension" priority="1" name="MokoWaaS Update Server">https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/updates.xml</server>
|
||||
</updateservers>
|
||||
<dlid prefix="dlid=" suffix=""/>
|
||||
<blockChildUninstall>true</blockChildUninstall>
|
||||
</extension>
|
||||
|
||||
+3
-312
@@ -34,20 +34,17 @@ class Pkg_MokowaasInstallerScript
|
||||
*/
|
||||
public function postflight($type, $parent)
|
||||
{
|
||||
// Remove legacy and retired extensions
|
||||
// Remove legacy extensions from before the package rewrite
|
||||
$this->cleanupLegacyExtensions();
|
||||
$this->removeRetiredExtensions();
|
||||
$this->migrateStandalonePlugins();
|
||||
|
||||
$this->enablePlugin('system', 'mokowaas');
|
||||
$this->enablePlugin('system', 'mokowaas_firewall');
|
||||
$this->enablePlugin('system', 'mokowaas_tenant');
|
||||
$this->enablePlugin('system', 'mokowaas_devtools');
|
||||
$this->enablePlugin('system', 'mokowaas_offline');
|
||||
$this->enablePlugin('system', 'mokowaas_monitor');
|
||||
$this->enablePlugin('webservices', 'mokowaas');
|
||||
$this->enablePlugin('task', 'mokowaasdemo');
|
||||
$this->enablePlugin('task', 'mokowaassync');
|
||||
$this->enablePlugin('task', 'mokowaas_tickets');
|
||||
|
||||
// Migrate params from core plugin to feature plugins (one-time)
|
||||
$this->migrateFeatureParams();
|
||||
@@ -55,9 +52,6 @@ class Pkg_MokowaasInstallerScript
|
||||
// Set up cpanel module on the admin dashboard
|
||||
$this->setupCpanelModule();
|
||||
|
||||
// Create Support portal menu item on frontend
|
||||
$this->setupSupportMenuItem();
|
||||
|
||||
// Mark MokoWaaS extensions as protected (prevents disable/uninstall at framework level)
|
||||
$this->protectExtensions();
|
||||
|
||||
@@ -132,196 +126,6 @@ class Pkg_MokowaasInstallerScript
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove extensions that have been retired and merged into core.
|
||||
*
|
||||
* plg_system_mokowaas_monitor was merged into the core plugin in 02.32.00.
|
||||
* Health monitoring is now built into plg_system_mokowaas directly.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
private function migrateStandalonePlugins(): void
|
||||
{
|
||||
// Migrate standalone MokoJoomTOS plugin to MokoWaaS Offline Bypass
|
||||
$migrations = [
|
||||
['old_element' => 'mokojoomtos', 'old_folder' => 'system', 'new_element' => 'mokowaas_offline', 'new_folder' => 'system'],
|
||||
];
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
foreach ($migrations as $m)
|
||||
{
|
||||
// Check if old plugin exists
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('extension_id'), $db->quoteName('params')])
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($m['old_element']))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote($m['old_folder']));
|
||||
$db->setQuery($query);
|
||||
$old = $db->loadObject();
|
||||
|
||||
if (!$old)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$oldParams = $old->params ?? '{}';
|
||||
|
||||
// Copy params to new plugin (only if new plugin has empty params)
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($m['new_element']))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote($m['new_folder']));
|
||||
$db->setQuery($query);
|
||||
$newParams = (string) $db->loadResult();
|
||||
|
||||
if (empty($newParams) || $newParams === '{}' || $newParams === '[]')
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($oldParams))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($m['new_element']))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote($m['new_folder']))
|
||||
)->execute();
|
||||
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
sprintf('Migrated settings from %s to %s.', $m['old_element'], $m['new_element']),
|
||||
'message'
|
||||
);
|
||||
}
|
||||
|
||||
// Unprotect old plugin
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('protected') . ' = 0')
|
||||
->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
|
||||
)->execute();
|
||||
|
||||
// Remove old extension record
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
|
||||
)->execute();
|
||||
|
||||
// Remove old update site entries
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__update_sites_extensions'))
|
||||
->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
|
||||
)->execute();
|
||||
|
||||
// Remove old files
|
||||
$dir = JPATH_PLUGINS . '/' . $m['old_folder'] . '/' . $m['old_element'];
|
||||
|
||||
if (is_dir($dir))
|
||||
{
|
||||
$this->rmdirRecursive($dir);
|
||||
}
|
||||
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
sprintf('Removed standalone %s plugin (replaced by %s).', $m['old_element'], $m['new_element']),
|
||||
'message'
|
||||
);
|
||||
|
||||
Log::add(
|
||||
sprintf('Migrated %s → %s and removed old plugin', $m['old_element'], $m['new_element']),
|
||||
Log::INFO,
|
||||
'mokowaas'
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Standalone plugin migration error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove extensions that have been retired and merged into core.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
private function removeRetiredExtensions(): void
|
||||
{
|
||||
$retired = [
|
||||
['type' => 'plugin', 'folder' => 'system', 'element' => 'mokowaas_monitor'],
|
||||
];
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
foreach ($retired as $ext)
|
||||
{
|
||||
// Check if installed
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('extension_id'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote($ext['type']))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote($ext['folder']))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($ext['element']));
|
||||
$db->setQuery($query);
|
||||
$extId = (int) $db->loadResult();
|
||||
|
||||
if (!$extId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unprotect so Joomla allows removal
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('protected') . ' = 0')
|
||||
->where($db->quoteName('extension_id') . ' = ' . $extId)
|
||||
)->execute();
|
||||
|
||||
// Remove extension record
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('extension_id') . ' = ' . $extId)
|
||||
)->execute();
|
||||
|
||||
// Remove files
|
||||
$dir = JPATH_PLUGINS . '/' . $ext['folder'] . '/' . $ext['element'];
|
||||
|
||||
if (is_dir($dir))
|
||||
{
|
||||
$this->rmdirRecursive($dir);
|
||||
}
|
||||
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
sprintf('Removed retired extension: %s/%s', $ext['folder'], $ext['element']),
|
||||
'message'
|
||||
);
|
||||
|
||||
Log::add(
|
||||
sprintf('Removed retired extension %s/%s (ID %d)', $ext['folder'], $ext['element'], $extId),
|
||||
Log::INFO,
|
||||
'mokowaas'
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Retired extension cleanup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively remove a directory.
|
||||
*
|
||||
@@ -407,12 +211,11 @@ class Pkg_MokowaasInstallerScript
|
||||
$db->quote('mokowaas_firewall'),
|
||||
$db->quote('mokowaas_tenant'),
|
||||
$db->quote('mokowaas_devtools'),
|
||||
$db->quote('mokowaas_offline'),
|
||||
$db->quote('mokowaas_monitor'),
|
||||
$db->quote('com_mokowaas'),
|
||||
$db->quote('mod_mokowaas_cpanel'),
|
||||
$db->quote('mokowaasdemo'),
|
||||
$db->quote('mokowaassync'),
|
||||
$db->quote('mokowaas_tickets'),
|
||||
$db->quote('perfectpublisher'),
|
||||
$db->quote('mokoonyx'),
|
||||
];
|
||||
@@ -704,118 +507,6 @@ class Pkg_MokowaasInstallerScript
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a "Support" menu item on the frontend main menu.
|
||||
*/
|
||||
private function setupSupportMenuItem(): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__menu'))
|
||||
->where($db->quoteName('link') . ' LIKE ' . $db->quote('%com_mokowaas&view=tickets%'))
|
||||
->where($db->quoteName('client_id') . ' = 0')
|
||||
);
|
||||
|
||||
if ((int) $db->loadResult() > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('extension_id'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
);
|
||||
$componentId = (int) $db->loadResult();
|
||||
|
||||
if (!$componentId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$db->setQuery("SELECT id FROM #__menu WHERE menutype = '' AND level = 0 AND client_id = 0 LIMIT 1");
|
||||
$rootId = (int) $db->loadResult() ?: 1;
|
||||
|
||||
$db->setQuery('SELECT MAX(rgt) FROM #__menu WHERE client_id = 0');
|
||||
$maxRgt = (int) $db->loadResult();
|
||||
|
||||
$item = (object) [
|
||||
'menutype' => 'mainmenu',
|
||||
'title' => 'Support',
|
||||
'alias' => 'support',
|
||||
'note' => '',
|
||||
'path' => 'support',
|
||||
'link' => 'index.php?option=com_mokowaas&view=tickets',
|
||||
'type' => 'component',
|
||||
'published' => 1,
|
||||
'parent_id' => $rootId,
|
||||
'level' => 1,
|
||||
'component_id' => $componentId,
|
||||
'checked_out' => null,
|
||||
'checked_out_time' => null,
|
||||
'browserNav' => 0,
|
||||
'access' => 2,
|
||||
'img' => '',
|
||||
'template_style_id' => 0,
|
||||
'params' => '{}',
|
||||
'lft' => $maxRgt + 1,
|
||||
'rgt' => $maxRgt + 2,
|
||||
'home' => 0,
|
||||
'language' => '*',
|
||||
'client_id' => 0,
|
||||
];
|
||||
|
||||
$db->insertObject('#__menu', $item, 'id');
|
||||
$supportId = (int) $item->id;
|
||||
|
||||
// Create "Submit a Ticket" child menu item
|
||||
if ($supportId)
|
||||
{
|
||||
$db->setQuery('SELECT MAX(rgt) FROM #__menu WHERE client_id = 0');
|
||||
$maxRgt2 = (int) $db->loadResult();
|
||||
|
||||
$child = (object) [
|
||||
'menutype' => 'mainmenu',
|
||||
'title' => 'Submit a Ticket',
|
||||
'alias' => 'submit-ticket',
|
||||
'note' => '',
|
||||
'path' => 'support/submit-ticket',
|
||||
'link' => 'index.php?option=com_mokowaas&view=tickets&layout=submit',
|
||||
'type' => 'component',
|
||||
'published' => 1,
|
||||
'parent_id' => $supportId,
|
||||
'level' => 2,
|
||||
'component_id' => $componentId,
|
||||
'checked_out' => null,
|
||||
'checked_out_time' => null,
|
||||
'browserNav' => 0,
|
||||
'access' => 2,
|
||||
'img' => '',
|
||||
'template_style_id' => 0,
|
||||
'params' => '{}',
|
||||
'lft' => $maxRgt2 + 1,
|
||||
'rgt' => $maxRgt2 + 2,
|
||||
'home' => 0,
|
||||
'language' => '*',
|
||||
'client_id' => 0,
|
||||
];
|
||||
|
||||
$db->insertObject('#__menu', $child, 'id');
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Support menu setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration of params from the monolithic core plugin to
|
||||
* the new feature plugins. Copies security, tenant, and dev params.
|
||||
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
VERSION: 02.32.38-dev
|
||||
-->
|
||||
|
||||
<updates>
|
||||
<update>
|
||||
<name>Package - MokoWaaS</name>
|
||||
<description>Package - MokoWaaS development build.</description>
|
||||
<element>pkg_mokowaas</element>
|
||||
<type>package</type>
|
||||
<client>site</client>
|
||||
<version>02.32.38-dev</version>
|
||||
<creationDate>2026-06-03</creationDate>
|
||||
<infourl title='Package - MokoWaaS'>https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/tag/development</infourl>
|
||||
<downloads>
|
||||
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/download/development/pkg_mokowaas-02.32.38-dev.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>86a9c67320ac162c60004706d3aef824cc91a5409ba4576a8a87e9fcddea86dd</sha256>
|
||||
<tags><tag>dev</tag></tags>
|
||||
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/CHANGELOG.md</changelogurl>
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
<targetplatform name="joomla" version="(5|6)\..*" />
|
||||
</update>
|
||||
<update>
|
||||
<name>Package - MokoWaaS</name>
|
||||
<description>Package - MokoWaaS stable build.</description>
|
||||
<element>pkg_mokowaas</element>
|
||||
<type>package</type>
|
||||
<client>site</client>
|
||||
<version>02.33.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<infourl title="Package - MokoWaaS">https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/tag/stable</infourl>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/download/stable/pkg_mokowaas-02.33.00.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>30e18531d9e453173c8945fdfaf690dacdcc3d584d827d146f0c86c851f49bb0</sha256>
|
||||
<tags><tag>stable</tag></tags>
|
||||
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/CHANGELOG.md</changelogurl>
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
<targetplatform name="joomla" version="(5|6)\..*"/>
|
||||
</update>
|
||||
</updates>
|
||||
Reference in New Issue
Block a user