Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be10092ef2 | |||
| 210aded6bc | |||
| 6588314026 | |||
| 9b693abe7d | |||
| 7c6e98a896 | |||
| 9ce1c14ae8 | |||
| 068738aa2f | |||
| 878a9b3726 | |||
| 5df8b0fc38 | |||
| 9484d6bde9 | |||
| 5407b712f1 | |||
| 75c34345f9 | |||
| 48d49b3ee0 | |||
| 3f63ec2e1d |
+1
-1
@@ -154,7 +154,7 @@ package-lock.json
|
||||
# PHP / Composer tooling
|
||||
# ============================================================
|
||||
vendor/
|
||||
!src/media/vendor/
|
||||
!source/media/vendor/
|
||||
composer.lock
|
||||
*.phar
|
||||
codeception.phar
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# MokoJoomCross
|
||||
|
||||
Cross-posting Joomla content to social media, email marketing, and chat platforms with plugin-based services.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Package** | `pkg_mokojoomcross` |
|
||||
| **Language** | PHP 8.1+ |
|
||||
| **Branch** | develop on `dev`, merge to `main` (protected) |
|
||||
| **Wiki** | [MokoJoomCross Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCross/wiki) |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
make build # Build package ZIP
|
||||
make lint # Run linters
|
||||
make validate # Validate structure
|
||||
make release # Full release pipeline
|
||||
make clean # Clean build artifacts
|
||||
composer install # Install PHP dependencies
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Joomla **package** with core extensions + pluggable service plugins:
|
||||
|
||||
### com_mokojoomcross (Component)
|
||||
- Admin backend: dashboard, services, post queue, templates, logs
|
||||
- Joomla 5/6 MVC: Dashboard, Services, Posts, Logs (list/edit each)
|
||||
- Namespace: `Joomla\Component\MokoJoomCross\Administrator`
|
||||
|
||||
### plg_system_mokojoomcross (System Plugin)
|
||||
- Hooks `onContentAfterSave` to trigger cross-posting on article publish
|
||||
- Dispatches to registered service plugins via `mokojoomcross` plugin group
|
||||
|
||||
### plg_content_mokojoomcross (Content Plugin)
|
||||
- Adds cross-post status badges to articles via `onContentBeforeDisplay`
|
||||
|
||||
### plg_webservices_mokojoomcross (WebServices Plugin)
|
||||
- REST API endpoints for posts and services
|
||||
|
||||
### Service Plugins (mokojoomcross group)
|
||||
Each platform is a separate plugin implementing `MokoJoomCrossServiceInterface`:
|
||||
- `plg_mokojoomcross_facebook` — Facebook/Meta Graph API
|
||||
- `plg_mokojoomcross_twitter` — X/Twitter API v2
|
||||
- `plg_mokojoomcross_linkedin` — LinkedIn Share API
|
||||
- `plg_mokojoomcross_mastodon` — Mastodon API
|
||||
- `plg_mokojoomcross_bluesky` — Bluesky AT Protocol
|
||||
- `plg_mokojoomcross_mailchimp` — Mailchimp Campaigns API
|
||||
- `plg_mokojoomcross_telegram` — Telegram Bot API
|
||||
- `plg_mokojoomcross_discord` — Discord Webhooks
|
||||
- `plg_mokojoomcross_slack` — Slack Incoming Webhooks
|
||||
|
||||
### Database Schema
|
||||
|
||||
- `#__mokojoomcross_services` — service configs (credentials as individual fields, not JSON)
|
||||
- `#__mokojoomcross_posts` — post queue (status: queued/posting/posted/failed/scheduled)
|
||||
- `#__mokojoomcross_templates` — message templates per service type
|
||||
- `#__mokojoomcross_logs` — activity logs with level and context
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js`
|
||||
- **Never commit** API keys, tokens, or credentials — these go in Joomla's encrypted params
|
||||
- **Attribution**: `Authored-by: Moko Consulting`
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
- **Minification**: handled at build time (CI)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files
|
||||
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
- **UX**: service credentials as individual form fields, not JSON blobs; dashboard link in toolbar
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- PHP 8.1+ minimum
|
||||
- Joomla 5/6 DI container pattern: `services/provider.php` → Extension class
|
||||
- Legacy stub `.php` file required for plugin loader but empty
|
||||
- `SubscriberInterface` for event subscription (not `on*` method naming)
|
||||
- `bind() → check() → store()` for Table operations (not `save()`)
|
||||
- Language file placement: site (no `folder`) vs admin (`folder="administrator"`)
|
||||
- SPDX license headers on all PHP files
|
||||
- Service plugins MUST implement `MokoJoomCrossServiceInterface`
|
||||
@@ -16,6 +16,11 @@
|
||||
<build>
|
||||
<language>PHP</language>
|
||||
<package-type>joomla-extension</package-type>
|
||||
<entry-point>src/</entry-point>
|
||||
<entry-point>source/</entry-point>
|
||||
</build>
|
||||
<licensing>
|
||||
<enabled>true</enabled>
|
||||
<dlid>true</dlid>
|
||||
<update-server>https://git.mokoconsulting.tech/{org}/{repo}/updates.xml</update-server>
|
||||
</licensing>
|
||||
</moko-platform>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||
# | joomla: XML manifest, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
@@ -71,20 +71,25 @@ jobs:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
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
|
||||
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/moko-platform
|
||||
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
rm -rf /tmp/moko-platform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
# 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" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/branch_rename.php \
|
||||
php ${MOKO_CLI}/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
@@ -100,16 +105,15 @@ jobs:
|
||||
|
||||
- name: Publish RC release
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--skip-update-stream
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
release:
|
||||
@@ -151,25 +155,60 @@ jobs:
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||
run: |
|
||||
# Ensure PHP + Composer are available
|
||||
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
|
||||
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/moko-platform
|
||||
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
rm -rf /tmp/moko-platform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
# 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" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability stable --bump minor --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--skip-update-stream
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||
else
|
||||
NOTES="Stable release"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
@@ -182,7 +221,7 @@ jobs:
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/release_mirror.php \
|
||||
php ${MOKO_CLI}/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||
@@ -256,7 +295,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/version_reset_dev.php \
|
||||
php ${MOKO_CLI}/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
- name: PHP syntax check
|
||||
run: |
|
||||
ERRORS=0
|
||||
for DIR in src/ htdocs/; do
|
||||
for DIR in source/ src/ htdocs/; do
|
||||
if [ -d "$DIR" ]; then
|
||||
FOUND=1
|
||||
while IFS= read -r -d '' FILE; do
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
fi
|
||||
# Check in common locations
|
||||
FOUND=0
|
||||
for BASE in "." "src" "htdocs"; do
|
||||
for BASE in "." "source" "src" "htdocs"; do
|
||||
if [ -f "${BASE}/${LANG_FILE}" ]; then
|
||||
FOUND=1
|
||||
break
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
MISSING=0
|
||||
CHECKED=0
|
||||
|
||||
for DIR in src/ htdocs/; do
|
||||
for DIR in source/ src/ htdocs/; do
|
||||
if [ -d "$DIR" ]; then
|
||||
while IFS= read -r -d '' SUBDIR; do
|
||||
CHECKED=$((CHECKED + 1))
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
done
|
||||
|
||||
if [ "${CHECKED}" -eq 0 ]; then
|
||||
echo "No src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
echo "No source/ or src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "${MISSING}" -gt 0 ]; then
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**${MISSING} director(ies) missing index.html out of ${CHECKED} checked.**" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -427,7 +427,7 @@ jobs:
|
||||
|
||||
# Determine source directory
|
||||
SRC_DIR=""
|
||||
for DIR in src/ htdocs/ lib/; do
|
||||
for DIR in source/ src/ htdocs/ lib/; do
|
||||
if [ -d "$DIR" ]; then
|
||||
SRC_DIR="$DIR"
|
||||
break
|
||||
@@ -435,7 +435,7 @@ jobs:
|
||||
done
|
||||
|
||||
if [ -z "$SRC_DIR" ]; then
|
||||
echo "No source directory found (src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
echo "No source directory found (source/, src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -159,11 +159,11 @@ jobs:
|
||||
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)
|
||||
done < <(find . -name "*.php" \( -path "*/source/*" -o -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
|
||||
echo "${ERRORS} file(s) are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "JEXEC guard: OK"
|
||||
@@ -172,7 +172,8 @@ jobs:
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
MISSING=0
|
||||
SOURCE_DIR="src"
|
||||
SOURCE_DIR="source"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
while IFS= read -r dir; do
|
||||
if [ ! -f "${dir}/index.html" ]; then
|
||||
@@ -220,7 +221,7 @@ jobs:
|
||||
echo "joomla.asset.json: valid"
|
||||
fi
|
||||
|
||||
# Validate all XML files in src/ are well-formed
|
||||
# Validate all XML files in source/src are well-formed
|
||||
XML_ERRORS=0
|
||||
if command -v php &> /dev/null; then
|
||||
while IFS= read -r -d '' xmlfile; do
|
||||
@@ -451,10 +452,11 @@ jobs:
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
SOURCE_DIR="source"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
echo "::warning::No source/, src/, or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
|
||||
@@ -1,234 +1,243 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- dev
|
||||
pull_request_target:
|
||||
types: [synchronize, opened, reopened]
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Pre-release channel'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- release-candidate
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
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 }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(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
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
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
|
||||
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" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
run: |
|
||||
# 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) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
|
||||
case "$STABILITY" in
|
||||
release-candidate) BUMP="minor" ;;
|
||||
*) BUMP="patch" ;;
|
||||
esac
|
||||
|
||||
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
|
||||
|
||||
# Set stability suffix and verify consistency
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
|
||||
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
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Append suffix for output
|
||||
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]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Auto-detect element via manifest_element.php
|
||||
php ${MOKO_CLI}/manifest_element.php \
|
||||
--path . --version "$VERSION" --stability "$STABILITY" \
|
||||
--repo "${GITEA_REPO}" --github-output
|
||||
|
||||
# Read back element outputs
|
||||
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
|
||||
|
||||
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}${SUFFIX} ==="
|
||||
|
||||
- name: Create release
|
||||
id: release
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch dev --prerelease
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
else
|
||||
NOTES="Release ${VERSION}"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
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
|
||||
|
||||
# updates.xml is generated dynamically by MokoGitea license server
|
||||
# No need to build, commit, or sync updates.xml from workflows
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
php ${MOKO_CLI}/release_cascade.php \
|
||||
--stability "${{ steps.meta.outputs.stability }}" \
|
||||
--token "${TOKEN}" \
|
||||
--api-base "${API_BASE}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- dev
|
||||
pull_request_target:
|
||||
types: [synchronize, opened, reopened]
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Pre-release channel'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- release-candidate
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
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 }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(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
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
# Use pre-installed /opt/moko-platform if available (updated by cron every 6h)
|
||||
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/cli/manifest_element.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/moko-platform
|
||||
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; 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
|
||||
rm -rf /tmp/moko-platform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
run: |
|
||||
# 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) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
|
||||
case "$STABILITY" in
|
||||
release-candidate) BUMP="minor" ;;
|
||||
*) BUMP="patch" ;;
|
||||
esac
|
||||
|
||||
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
|
||||
|
||||
# Set stability suffix and verify consistency
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
|
||||
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
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
|
||||
php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Append suffix for output
|
||||
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]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Auto-detect element via manifest_element.php
|
||||
php ${MOKO_CLI}/manifest_element.php \
|
||||
--path . --version "$VERSION" --stability "$STABILITY" \
|
||||
--repo "${GITEA_REPO}" --github-output
|
||||
|
||||
# Read back element outputs
|
||||
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
|
||||
|
||||
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}${SUFFIX} ==="
|
||||
|
||||
- name: Create release
|
||||
id: release
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch dev --prerelease
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
else
|
||||
NOTES="Release ${VERSION}"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
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
|
||||
|
||||
# updates.xml is generated dynamically by MokoGitea license server
|
||||
# No need to build, commit, or sync updates.xml from workflows
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
php ${MOKO_CLI}/release_cascade.php \
|
||||
--stability "${{ steps.meta.outputs.stability }}" \
|
||||
--token "${TOKEN}" \
|
||||
--api-base "${API_BASE}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -296,17 +296,19 @@ jobs:
|
||||
missing_required=()
|
||||
missing_optional=()
|
||||
|
||||
# Source directory: src/ or htdocs/ (either is valid for extension repos)
|
||||
# Source directory: source/, src/, or htdocs/ (any is valid for extension repos)
|
||||
SOURCE_DIR=""
|
||||
if [ -d "src" ]; then
|
||||
if [ -d "source" ]; then
|
||||
SOURCE_DIR="source"
|
||||
elif [ -d "src" ]; then
|
||||
SOURCE_DIR="src"
|
||||
elif [ -d "htdocs" ]; then
|
||||
SOURCE_DIR="htdocs"
|
||||
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
|
||||
# Platform/tooling repos don't need src/
|
||||
# Platform/tooling repos don't need source/
|
||||
SOURCE_DIR=""
|
||||
else
|
||||
missing_required+=("src/ or htdocs/ (source directory required)")
|
||||
missing_required+=("source/ or src/ or htdocs/ (source directory required)")
|
||||
fi
|
||||
|
||||
for item in "${required_artifacts[@]}"; do
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**MokoJoomCross** -- Cross-posting Joomla content to social media, email marketing, and chat platforms
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Platform** | joomla |
|
||||
| **Language** | PHP |
|
||||
| **Default branch** | main |
|
||||
| **License** | GPL-3.0-or-later |
|
||||
| **Wiki** | [MokoJoomCross Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCross/wiki) |
|
||||
| **Standards** | [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home) |
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
make build # Build the project
|
||||
make lint # Run linters
|
||||
make validate # Validate structure
|
||||
make release # Full release pipeline
|
||||
make minify # Minify CSS/JS assets
|
||||
make clean # Clean build artifacts
|
||||
```
|
||||
|
||||
```bash
|
||||
composer install # Install PHP dependencies
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a Joomla **package** extension (`pkg_mokojoomcross`) containing sub-extensions:
|
||||
|
||||
### com_mokojoomcross (Component)
|
||||
- Admin backend for managing services, post queue, templates, and logs
|
||||
- Joomla 5/6 MVC: Dashboard, Services, Posts, Logs (list/edit each)
|
||||
- Namespace: `Joomla\Component\MokoJoomCross\Administrator`
|
||||
- Database tables: `#__mokojoomcross_services`, `#__mokojoomcross_posts`, `#__mokojoomcross_templates`, `#__mokojoomcross_logs`
|
||||
|
||||
### plg_system_mokojoomcross (System Plugin)
|
||||
- Hooks `onContentAfterSave` to trigger cross-posting when articles are published
|
||||
- Dispatches to registered service plugins via the `mokojoomcross` plugin group
|
||||
- Namespace: `Joomla\Plugin\System\MokoJoomCross`
|
||||
|
||||
### plg_content_mokojoomcross (Content Plugin)
|
||||
- Hooks `onContentBeforeDisplay` to add cross-post status badges to articles
|
||||
- Namespace: `Joomla\Plugin\Content\MokoJoomCross`
|
||||
|
||||
### plg_webservices_mokojoomcross (WebServices Plugin)
|
||||
- REST API endpoints for posts and services
|
||||
- Namespace: `Joomla\Plugin\WebServices\MokoJoomCross`
|
||||
|
||||
### Service Plugins (mokojoomcross group)
|
||||
Each platform is a separate plugin in the custom `mokojoomcross` plugin group:
|
||||
- `plg_mokojoomcross_facebook` — Facebook/Meta Graph API
|
||||
- `plg_mokojoomcross_twitter` — X/Twitter API v2
|
||||
- `plg_mokojoomcross_linkedin` — LinkedIn Share API
|
||||
- `plg_mokojoomcross_mastodon` — Mastodon API
|
||||
- `plg_mokojoomcross_bluesky` — Bluesky AT Protocol
|
||||
- `plg_mokojoomcross_mailchimp` — Mailchimp Campaigns API
|
||||
- `plg_mokojoomcross_telegram` — Telegram Bot API (default @MokoWaaSBot + custom bot)
|
||||
- `plg_mokojoomcross_discord` — Discord Webhooks
|
||||
- `plg_mokojoomcross_slack` — Slack Incoming Webhooks
|
||||
|
||||
### Database Schema
|
||||
|
||||
Four tables:
|
||||
|
||||
`#__mokojoomcross_services`:
|
||||
- `id`, `title`, `alias`, `service_type` (facebook, twitter, etc.)
|
||||
- `credentials` (JSON encrypted), `params` (JSON)
|
||||
- `published`, `ordering`, `created`, `modified`, `created_by`
|
||||
|
||||
`#__mokojoomcross_posts`:
|
||||
- `id`, `article_id` (FK to #__content), `service_id` (FK)
|
||||
- `status` (queued/posting/posted/failed/scheduled)
|
||||
- `message`, `platform_post_id`, `platform_response` (JSON)
|
||||
- `scheduled_at`, `posted_at`, `retry_count`
|
||||
- `created`, `modified`
|
||||
|
||||
`#__mokojoomcross_templates`:
|
||||
- `id`, `service_type`, `title`, `template_body`
|
||||
- `published`, `ordering`, `created`, `modified`
|
||||
|
||||
`#__mokojoomcross_logs`:
|
||||
- `id`, `post_id` (FK), `service_id` (FK)
|
||||
- `level` (info/warning/error), `message`, `context` (JSON)
|
||||
- `created`
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
|
||||
- **Never commit** API keys, tokens, or credentials — these go in Joomla's encrypted params
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits
|
||||
- **Branch strategy**: develop on `dev`, merge to `main` for release
|
||||
- **Minification**: handled at build time (CI)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
|
||||
- **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- PHP 8.1+ minimum
|
||||
- Joomla 5/6 DI container pattern: `services/provider.php` → Extension class
|
||||
- Legacy stub `.php` file required for plugin loader but empty
|
||||
- `SubscriberInterface` for event subscription (not `on*` method naming)
|
||||
- `bind() → check() → store()` for Table operations (not `save()`)
|
||||
- Language file placement: site (no `folder`) vs admin (`folder="administrator"`)
|
||||
- SPDX license headers on all PHP files
|
||||
- Service plugins MUST implement `MokoJoomCrossServiceInterface`
|
||||
@@ -23,7 +23,7 @@ PLUGIN_GROUP := system
|
||||
# Options: system, content, user, authentication, etc.
|
||||
|
||||
# Directories
|
||||
SRC_DIR := src
|
||||
SRC_DIR := source
|
||||
BUILD_DIR := build
|
||||
DIST_DIR := dist
|
||||
DOCS_DIR := docs
|
||||
|
||||
+7
@@ -55,6 +55,13 @@ class DispatchController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
// ACL check — require core.manage on the component
|
||||
if (!Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokojoomcross')) {
|
||||
$this->sendJsonResponse(['error' => 'Forbidden'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Read JSON body
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$articleId = (int) ($input['article_id'] ?? 0);
|
||||
+2
@@ -36,6 +36,8 @@ class OauthController extends BaseController
|
||||
*/
|
||||
public function authorize(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
$serviceId = $this->input->getInt('service_id', 0);
|
||||
|
||||
if (!$serviceId) {
|
||||
+4
-1
@@ -76,7 +76,10 @@ class PostsController extends AdminController
|
||||
->set($db->quoteName('scheduled_at') . ' = ' . $db->quote($scheduledAt))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote('queued'))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $id);
|
||||
->where($db->quoteName('id') . ' = ' . (int) $id)
|
||||
->where($db->quoteName('status') . ' IN ('
|
||||
. $db->quote('queued') . ',' . $db->quote('failed') . ','
|
||||
. $db->quote('permanently_failed') . ',' . $db->quote('cancelled') . ')');
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
+17
-6
@@ -29,6 +29,12 @@ class ServiceController extends FormController
|
||||
*/
|
||||
public function testConnection(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokojoomcross')) {
|
||||
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||
}
|
||||
|
||||
$app = $this->app;
|
||||
$id = (int) $this->input->getInt('id', 0);
|
||||
|
||||
@@ -50,14 +56,19 @@ class ServiceController extends FormController
|
||||
throw new \RuntimeException(Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_NOT_FOUND'));
|
||||
}
|
||||
|
||||
// Get service plugins via dispatcher
|
||||
// Get service plugins via dispatcher (Joomla 5+ Event ArrayAccess pattern)
|
||||
PluginHelper::importPlugin('mokojoomcross');
|
||||
|
||||
$servicePlugins = [];
|
||||
$app->getDispatcher()->dispatch(
|
||||
'onMokoJoomCrossGetServices',
|
||||
new \Joomla\Event\Event('onMokoJoomCrossGetServices', [&$servicePlugins])
|
||||
);
|
||||
$event = new \Joomla\Event\Event('onMokoJoomCrossGetServices', [$servicePlugins]);
|
||||
$app->getDispatcher()->dispatch('onMokoJoomCrossGetServices', $event);
|
||||
|
||||
$idx = 1;
|
||||
|
||||
while (isset($event[$idx])) {
|
||||
$servicePlugins[] = $event[$idx];
|
||||
$idx++;
|
||||
}
|
||||
|
||||
// Find the matching plugin
|
||||
$plugin = null;
|
||||
@@ -74,7 +85,7 @@ class ServiceController extends FormController
|
||||
}
|
||||
|
||||
// Decode credentials and validate
|
||||
$credentials = json_decode($service->credentials ?: '{}', true) ?: [];
|
||||
$credentials = \Joomla\Component\MokoJoomCross\Administrator\Helper\CredentialHelper::decrypt($service->credentials ?: '');
|
||||
$result = $plugin->validateCredentials($credentials);
|
||||
|
||||
$app->mimeType = 'application/json';
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomCross
|
||||
* @subpackage com_mokojoomcross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoJoomCross\Administrator\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
/**
|
||||
* Encrypts and decrypts service credentials using libsodium.
|
||||
*
|
||||
* Uses Joomla's $secret from configuration.php as the key source.
|
||||
* Falls back to plaintext JSON if sodium is unavailable or decryption
|
||||
* fails (backward compat with existing unencrypted credentials).
|
||||
*/
|
||||
class CredentialHelper
|
||||
{
|
||||
private const PREFIX = 'enc:sodium:';
|
||||
|
||||
/**
|
||||
* Encrypt a credentials array to a storable string.
|
||||
*
|
||||
* @param array $credentials Credentials to encrypt
|
||||
*
|
||||
* @return string Encrypted string prefixed with "enc:sodium:", or plain JSON as fallback
|
||||
*/
|
||||
public static function encrypt(array $credentials): string
|
||||
{
|
||||
$json = json_encode($credentials);
|
||||
|
||||
if (!function_exists('sodium_crypto_secretbox')) {
|
||||
return $json;
|
||||
}
|
||||
|
||||
try {
|
||||
$key = self::deriveKey();
|
||||
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||
$cipher = sodium_crypto_secretbox($json, $nonce, $key);
|
||||
|
||||
return self::PREFIX . base64_encode($nonce . $cipher);
|
||||
} catch (\Throwable $e) {
|
||||
return $json;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a credentials string back to an array.
|
||||
*
|
||||
* Handles both encrypted (prefixed) and legacy plaintext JSON.
|
||||
*
|
||||
* @param string $stored Stored credential string
|
||||
*
|
||||
* @return array Decoded credentials
|
||||
*/
|
||||
public static function decrypt(string $stored): array
|
||||
{
|
||||
if (empty($stored)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Legacy plaintext JSON — no prefix
|
||||
if (!str_starts_with($stored, self::PREFIX)) {
|
||||
return json_decode($stored, true) ?: [];
|
||||
}
|
||||
|
||||
if (!function_exists('sodium_crypto_secretbox_open')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$key = self::deriveKey();
|
||||
$payload = base64_decode(substr($stored, strlen(self::PREFIX)));
|
||||
|
||||
if ($payload === false || strlen($payload) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$nonce = substr($payload, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||
$cipher = substr($payload, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||
$plain = sodium_crypto_secretbox_open($cipher, $nonce, $key);
|
||||
|
||||
if ($plain === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return json_decode($plain, true) ?: [];
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a 32-byte encryption key from Joomla's secret.
|
||||
*/
|
||||
private static function deriveKey(): string
|
||||
{
|
||||
$secret = Factory::getApplication()->get('secret', '');
|
||||
|
||||
return sodium_crypto_generichash($secret, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
|
||||
}
|
||||
}
|
||||
+93
-75
@@ -154,6 +154,9 @@ class CrossPostDispatcher
|
||||
$templateMap[$row->service_type] = $row->template_body;
|
||||
}
|
||||
|
||||
// Pre-build article metadata once (category, author, tags) — avoids N queries per service
|
||||
$articleMeta = self::buildArticleMeta($article);
|
||||
|
||||
foreach ($services as $service) {
|
||||
// Category routing filter — if rules exist, only post to whitelisted services
|
||||
if ($categoryServiceIds !== null && !in_array((int) $service->id, $categoryServiceIds, true)) {
|
||||
@@ -174,7 +177,7 @@ class CrossPostDispatcher
|
||||
continue;
|
||||
}
|
||||
|
||||
$message = self::renderTemplate($article, $service, $templateMap);
|
||||
$message = self::renderTemplate($article, $service, $templateMap, $articleMeta);
|
||||
|
||||
// Extract intro image for media attachment
|
||||
$media = [];
|
||||
@@ -236,7 +239,7 @@ class CrossPostDispatcher
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
$credentials = json_decode($service->credentials ?: '{}', true) ?: [];
|
||||
$credentials = CredentialHelper::decrypt($service->credentials ?: '');
|
||||
$params = json_decode($service->params ?: '{}', true) ?: [];
|
||||
|
||||
if (!empty($articleUrl)) {
|
||||
@@ -343,9 +346,93 @@ class CrossPostDispatcher
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the message template for a service.
|
||||
* Build article metadata (category, author, tags, image) for template rendering.
|
||||
* Call once per article, then pass to renderTemplate() for each service.
|
||||
*
|
||||
* @param object $article Article object
|
||||
*
|
||||
* @return array Pre-resolved metadata for template placeholders
|
||||
*/
|
||||
public static function renderTemplate(object $article, object $service, array $templateMap = []): string
|
||||
public static function buildArticleMeta(object $article): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$url = $article->_article_url
|
||||
?? (Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id
|
||||
. (!empty($article->catid) ? '&catid=' . $article->catid : ''));
|
||||
|
||||
$categoryName = '';
|
||||
|
||||
if (!empty($article->catid)) {
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('title'))
|
||||
->from($db->quoteName('#__categories'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $article->catid);
|
||||
$db->setQuery($query);
|
||||
$categoryName = $db->loadResult() ?: '';
|
||||
}
|
||||
|
||||
$authorName = '';
|
||||
|
||||
if (!empty($article->created_by)) {
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('name'))
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $article->created_by);
|
||||
$db->setQuery($query);
|
||||
$authorName = $db->loadResult() ?: '';
|
||||
}
|
||||
|
||||
$introImage = '';
|
||||
$images = json_decode($article->images ?? '{}');
|
||||
|
||||
if (!empty($images->image_intro)) {
|
||||
$introImage = Uri::root() . ltrim($images->image_intro, '/');
|
||||
}
|
||||
|
||||
$tagNames = [];
|
||||
|
||||
if (!empty($article->id)) {
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('t.title'))
|
||||
->from($db->quoteName('#__tags', 't'))
|
||||
->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm')
|
||||
. ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id'))
|
||||
->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article'))
|
||||
->where($db->quoteName('m.content_item_id') . ' = ' . (int) $article->id)
|
||||
->where($db->quoteName('t.published') . ' = 1');
|
||||
$db->setQuery($query);
|
||||
$tagNames = $db->loadColumn() ?: [];
|
||||
}
|
||||
|
||||
$tagsComma = implode(', ', $tagNames);
|
||||
$hashtags = implode(' ', array_map(function ($tag) {
|
||||
return '#' . preg_replace('/\s+/', '', $tag);
|
||||
}, $tagNames));
|
||||
|
||||
return [
|
||||
'{title}' => $article->title ?? '',
|
||||
'{introtext}' => strip_tags(mb_substr($article->introtext ?? '', 0, 280)),
|
||||
'{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)),
|
||||
'{url}' => $url,
|
||||
'{image}' => $introImage,
|
||||
'{category}' => $categoryName,
|
||||
'{author}' => $authorName,
|
||||
'{date}' => Factory::getDate($article->publish_up ?? 'now')->format('Y-m-d'),
|
||||
'{tags}' => $tagsComma,
|
||||
'{hashtags}' => $hashtags,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the message template for a service.
|
||||
*
|
||||
* @param object $article Article object
|
||||
* @param object $service Service object
|
||||
* @param array $templateMap Pre-loaded template map (service_type => body)
|
||||
* @param array $articleMeta Pre-built article metadata from buildArticleMeta()
|
||||
*/
|
||||
public static function renderTemplate(object $article, object $service, array $templateMap = [], array $articleMeta = []): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
@@ -367,77 +454,8 @@ class CrossPostDispatcher
|
||||
$template = $db->loadResult() ?: "{title}\n\n{url}";
|
||||
}
|
||||
|
||||
// Build SEF article URL
|
||||
$url = $article->_article_url
|
||||
?? (Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id
|
||||
. (!empty($article->catid) ? '&catid=' . $article->catid : ''));
|
||||
|
||||
// Resolve category name
|
||||
$categoryName = '';
|
||||
|
||||
if (!empty($article->catid)) {
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('title'))
|
||||
->from($db->quoteName('#__categories'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $article->catid);
|
||||
$db->setQuery($query);
|
||||
$categoryName = $db->loadResult() ?: '';
|
||||
}
|
||||
|
||||
// Resolve author name
|
||||
$authorName = '';
|
||||
|
||||
if (!empty($article->created_by)) {
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('name'))
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $article->created_by);
|
||||
$db->setQuery($query);
|
||||
$authorName = $db->loadResult() ?: '';
|
||||
}
|
||||
|
||||
// Extract intro image
|
||||
$introImage = '';
|
||||
$images = json_decode($article->images ?? '{}');
|
||||
|
||||
if (!empty($images->image_intro)) {
|
||||
$introImage = Uri::root() . ltrim($images->image_intro, '/');
|
||||
}
|
||||
|
||||
// Resolve article tags
|
||||
$tagNames = [];
|
||||
|
||||
if (!empty($article->id)) {
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('t.title'))
|
||||
->from($db->quoteName('#__tags', 't'))
|
||||
->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm')
|
||||
. ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id'))
|
||||
->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article'))
|
||||
->where($db->quoteName('m.content_item_id') . ' = ' . (int) $article->id)
|
||||
->where($db->quoteName('t.published') . ' = 1');
|
||||
$db->setQuery($query);
|
||||
$tagNames = $db->loadColumn() ?: [];
|
||||
}
|
||||
|
||||
$tagsComma = implode(', ', $tagNames);
|
||||
$hashtags = implode(' ', array_map(function ($tag) {
|
||||
return '#' . preg_replace('/\s+/', '', $tag);
|
||||
}, $tagNames));
|
||||
|
||||
// Replace placeholders
|
||||
$replacements = [
|
||||
'{title}' => $article->title ?? '',
|
||||
'{introtext}' => strip_tags(mb_substr($article->introtext ?? '', 0, 280)),
|
||||
'{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)),
|
||||
'{url}' => $url,
|
||||
'{image}' => $introImage,
|
||||
'{category}' => $categoryName,
|
||||
'{author}' => $authorName,
|
||||
'{date}' => Factory::getDate($article->publish_up ?? 'now')->format('Y-m-d'),
|
||||
'{tags}' => $tagsComma,
|
||||
'{hashtags}' => $hashtags,
|
||||
];
|
||||
// Use pre-built metadata if available, otherwise build on the fly
|
||||
$replacements = !empty($articleMeta) ? $articleMeta : self::buildArticleMeta($article);
|
||||
|
||||
$message = str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ class OAuthHelper
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__mokojoomcross_services'))
|
||||
->set($db->quoteName('credentials') . ' = ' . $db->quote(json_encode($credentials)))
|
||||
->set($db->quoteName('credentials') . ' = ' . $db->quote(CredentialHelper::encrypt($credentials)))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql()))
|
||||
->where($db->quoteName('id') . ' = ' . $serviceId);
|
||||
|
||||
+83
-40
@@ -17,6 +17,7 @@ use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Component\MokoJoomCross\Administrator\Helper\CredentialHelper;
|
||||
use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface;
|
||||
|
||||
/**
|
||||
@@ -146,7 +147,7 @@ class QueueProcessor
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
$credentials = json_decode($post->credentials ?: '{}', true) ?: [];
|
||||
$credentials = CredentialHelper::decrypt($post->credentials ?: '');
|
||||
$params = json_decode($post->service_params ?: '{}', true) ?: [];
|
||||
|
||||
// Token auto-refresh before posting
|
||||
@@ -346,6 +347,40 @@ class QueueProcessor
|
||||
// they are loaded in case any lifecycle events depend on them)
|
||||
PluginHelper::importPlugin('mokojoomcross');
|
||||
|
||||
// Batch pre-load: latest posted_at per article+service (eliminates N*M queries)
|
||||
$articleIds = implode(',', array_map(function ($a) { return (int) $a->id; }, $articles));
|
||||
$serviceIds = implode(',', array_map(function ($s) { return (int) $s->id; }, $services));
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select(['article_id', 'service_id', 'MAX(' . $db->quoteName('posted_at') . ') AS last_posted'])
|
||||
->from($db->quoteName('#__mokojoomcross_posts'))
|
||||
->where($db->quoteName('article_id') . ' IN (' . $articleIds . ')')
|
||||
->where($db->quoteName('service_id') . ' IN (' . $serviceIds . ')')
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('posted'))
|
||||
->group(['article_id', 'service_id']);
|
||||
$db->setQuery($query);
|
||||
$lastPostedRows = $db->loadObjectList() ?: [];
|
||||
|
||||
$lastPostedMap = [];
|
||||
foreach ($lastPostedRows as $row) {
|
||||
$lastPostedMap[$row->article_id . ':' . $row->service_id] = $row->last_posted;
|
||||
}
|
||||
|
||||
// Batch pre-load: existing queued/posting entries
|
||||
$query = $db->getQuery(true)
|
||||
->select(['article_id', 'service_id'])
|
||||
->from($db->quoteName('#__mokojoomcross_posts'))
|
||||
->where($db->quoteName('article_id') . ' IN (' . $articleIds . ')')
|
||||
->where($db->quoteName('service_id') . ' IN (' . $serviceIds . ')')
|
||||
->where($db->quoteName('status') . ' IN (' . $db->quote('queued') . ',' . $db->quote('posting') . ')');
|
||||
$db->setQuery($query);
|
||||
$pendingRows = $db->loadObjectList() ?: [];
|
||||
|
||||
$pendingSet = [];
|
||||
foreach ($pendingRows as $row) {
|
||||
$pendingSet[$row->article_id . ':' . $row->service_id] = true;
|
||||
}
|
||||
|
||||
foreach ($articles as $article) {
|
||||
if ($result['queued'] >= $maxPerRun) {
|
||||
break;
|
||||
@@ -380,18 +415,10 @@ class QueueProcessor
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check last successful post for this article+service
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('posted_at'))
|
||||
->from($db->quoteName('#__mokojoomcross_posts'))
|
||||
->where($db->quoteName('article_id') . ' = ' . (int) $article->id)
|
||||
->where($db->quoteName('service_id') . ' = ' . (int) $service->id)
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('posted'))
|
||||
->order($db->quoteName('posted_at') . ' DESC')
|
||||
->setLimit(1);
|
||||
$key = $article->id . ':' . $service->id;
|
||||
|
||||
$db->setQuery($query);
|
||||
$lastPosted = $db->loadResult();
|
||||
// Check last successful post from batch-loaded map
|
||||
$lastPosted = $lastPostedMap[$key] ?? null;
|
||||
|
||||
if (empty($lastPosted)) {
|
||||
// Never posted — skip, the initial cross-post will handle it
|
||||
@@ -399,25 +426,14 @@ class QueueProcessor
|
||||
}
|
||||
|
||||
// Check if interval has elapsed
|
||||
$lastDate = Factory::getDate($lastPosted);
|
||||
$dueDate = Factory::getDate($lastPosted . ' + ' . $interval . ' days');
|
||||
$dueDate = Factory::getDate($lastPosted . ' + ' . $interval . ' days');
|
||||
|
||||
if ($dueDate->toUnix() > Factory::getDate()->toUnix()) {
|
||||
// Not due yet
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if there's already a queued/posting entry
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokojoomcross_posts'))
|
||||
->where($db->quoteName('article_id') . ' = ' . (int) $article->id)
|
||||
->where($db->quoteName('service_id') . ' = ' . (int) $service->id)
|
||||
->where($db->quoteName('status') . ' IN (' . $db->quote('queued') . ',' . $db->quote('posting') . ')');
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
if ((int) $db->loadResult() > 0) {
|
||||
if (isset($pendingSet[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -642,8 +658,7 @@ class QueueProcessor
|
||||
$componentParams = ComponentHelper::getParams('com_mokojoomcross');
|
||||
$maxRetry = (int) $componentParams->get('retry_max', 3);
|
||||
$retryDelay = (int) $componentParams->get('retry_delay', 300);
|
||||
$retryAfter = Factory::getDate('now - ' . $retryDelay . ' seconds')->toSql();
|
||||
$now = Factory::getDate()->toSql();
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
// Queued posts ready to go
|
||||
$query = $db->getQuery(true)
|
||||
@@ -655,13 +670,14 @@ class QueueProcessor
|
||||
$db->setQuery($query);
|
||||
$queued = (int) $db->loadResult();
|
||||
|
||||
// Failed posts eligible for retry
|
||||
// Failed posts eligible for retry (exponential backoff matching processQueue)
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokojoomcross_posts'))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('failed'))
|
||||
->where($db->quoteName('retry_count') . ' < ' . $maxRetry)
|
||||
->where($db->quoteName('modified') . ' <= ' . $db->quote($retryAfter));
|
||||
->where($db->quoteName('modified') . ' <= DATE_SUB(NOW(), INTERVAL ('
|
||||
. (int) $retryDelay . ' * POW(2, ' . $db->quoteName('retry_count') . ')) SECOND)');
|
||||
$db->setQuery($query);
|
||||
$retryable = (int) $db->loadResult();
|
||||
|
||||
@@ -796,31 +812,58 @@ class QueueProcessor
|
||||
/**
|
||||
* Timestamp-based lock fallback for databases without advisory locks.
|
||||
*
|
||||
* Uses the component params to store a lock timestamp. Considers the lock
|
||||
* stale after 120 seconds to prevent deadlocks from crashed processes.
|
||||
* Uses an atomic UPDATE with a WHERE clause to prevent TOCTOU race
|
||||
* conditions. The lock is considered stale after 120 seconds.
|
||||
*/
|
||||
private static function acquireTimestampLock($db): bool
|
||||
{
|
||||
$params = ComponentHelper::getParams('com_mokojoomcross');
|
||||
$lockTime = (int) $params->get('queue_lock_time', 0);
|
||||
$now = time();
|
||||
$staleThreshold = $now - 120;
|
||||
|
||||
if ($lockTime > 0 && ($now - $lockTime) < 120) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Atomic: only succeeds if lock is absent (0) or stale
|
||||
$params = ComponentHelper::getParams('com_mokojoomcross');
|
||||
$oldParams = $params->toString();
|
||||
$params->set('queue_lock_time', $now);
|
||||
$newParams = $params->toString();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($newParams))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokojoomcross'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
->where('(' . $db->quoteName('params') . ' NOT LIKE ' . $db->quote('%"queue_lock_time"%')
|
||||
. ' OR ' . $db->quoteName('params') . ' LIKE ' . $db->quote('%"queue_lock_time":0%')
|
||||
. ' OR ' . $db->quoteName('params') . ' LIKE ' . $db->quote('%"queue_lock_time":"0"%')
|
||||
. ')');
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
return true;
|
||||
if ($db->getAffectedRows() > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the existing lock is stale
|
||||
$params = ComponentHelper::getParams('com_mokojoomcross');
|
||||
$lockTime = (int) $params->get('queue_lock_time', 0);
|
||||
|
||||
if ($lockTime > 0 && $lockTime <= $staleThreshold) {
|
||||
// Force acquire stale lock
|
||||
$params->set('queue_lock_time', $now);
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokojoomcross'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
+5
-3
@@ -55,7 +55,7 @@ class ServiceModel extends AdminModel
|
||||
$data = $this->getItem();
|
||||
|
||||
if ($data && !empty($data->credentials)) {
|
||||
$credentials = json_decode($data->credentials, true) ?: [];
|
||||
$credentials = \Joomla\Component\MokoJoomCross\Administrator\Helper\CredentialHelper::decrypt($data->credentials);
|
||||
$serviceType = $data->service_type ?? '';
|
||||
|
||||
foreach ($credentials as $key => $value) {
|
||||
@@ -106,8 +106,10 @@ class ServiceModel extends AdminModel
|
||||
}
|
||||
}
|
||||
|
||||
// Store the credentials JSON
|
||||
$data['credentials'] = !empty($credentials) ? json_encode($credentials) : '{}';
|
||||
// Store credentials encrypted
|
||||
$data['credentials'] = !empty($credentials)
|
||||
? \Joomla\Component\MokoJoomCross\Administrator\Helper\CredentialHelper::encrypt($credentials)
|
||||
: '{}';
|
||||
|
||||
// Remove individual cred_* fields so they don't cause column-not-found errors
|
||||
foreach (array_keys($data) as $key) {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user