Compare commits
9 Commits
development
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 068738aa2f | |||
| 878a9b3726 | |||
| 5df8b0fc38 | |||
| 9484d6bde9 | |||
| 5407b712f1 | |||
| 75c34345f9 | |||
| 48d49b3ee0 | |||
| 3f63ec2e1d | |||
| 1894abcf90 |
+1
-1
@@ -154,7 +154,7 @@ package-lock.json
|
|||||||
# PHP / Composer tooling
|
# PHP / Composer tooling
|
||||||
# ============================================================
|
# ============================================================
|
||||||
vendor/
|
vendor/
|
||||||
!src/media/vendor/
|
!source/media/vendor/
|
||||||
composer.lock
|
composer.lock
|
||||||
*.phar
|
*.phar
|
||||||
codeception.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>
|
<build>
|
||||||
<language>PHP</language>
|
<language>PHP</language>
|
||||||
<package-type>joomla-extension</package-type>
|
<package-type>joomla-extension</package-type>
|
||||||
<entry-point>src/</entry-point>
|
<entry-point>source/</entry-point>
|
||||||
</build>
|
</build>
|
||||||
|
<licensing>
|
||||||
|
<enabled>true</enabled>
|
||||||
|
<dlid>true</dlid>
|
||||||
|
<update-server>https://git.mokoconsulting.tech/{org}/{repo}/updates.xml</update-server>
|
||||||
|
</licensing>
|
||||||
</moko-platform>
|
</moko-platform>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
- name: PHP syntax check
|
- name: PHP syntax check
|
||||||
run: |
|
run: |
|
||||||
ERRORS=0
|
ERRORS=0
|
||||||
for DIR in src/ htdocs/; do
|
for DIR in source/ src/ htdocs/; do
|
||||||
if [ -d "$DIR" ]; then
|
if [ -d "$DIR" ]; then
|
||||||
FOUND=1
|
FOUND=1
|
||||||
while IFS= read -r -d '' FILE; do
|
while IFS= read -r -d '' FILE; do
|
||||||
@@ -174,7 +174,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
# Check in common locations
|
# Check in common locations
|
||||||
FOUND=0
|
FOUND=0
|
||||||
for BASE in "." "src" "htdocs"; do
|
for BASE in "." "source" "src" "htdocs"; do
|
||||||
if [ -f "${BASE}/${LANG_FILE}" ]; then
|
if [ -f "${BASE}/${LANG_FILE}" ]; then
|
||||||
FOUND=1
|
FOUND=1
|
||||||
break
|
break
|
||||||
@@ -207,7 +207,7 @@ jobs:
|
|||||||
MISSING=0
|
MISSING=0
|
||||||
CHECKED=0
|
CHECKED=0
|
||||||
|
|
||||||
for DIR in src/ htdocs/; do
|
for DIR in source/ src/ htdocs/; do
|
||||||
if [ -d "$DIR" ]; then
|
if [ -d "$DIR" ]; then
|
||||||
while IFS= read -r -d '' SUBDIR; do
|
while IFS= read -r -d '' SUBDIR; do
|
||||||
CHECKED=$((CHECKED + 1))
|
CHECKED=$((CHECKED + 1))
|
||||||
@@ -220,7 +220,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [ "${CHECKED}" -eq 0 ]; then
|
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
|
elif [ "${MISSING}" -gt 0 ]; then
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "**${MISSING} director(ies) missing index.html out of ${CHECKED} checked.**" >> $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
|
# Determine source directory
|
||||||
SRC_DIR=""
|
SRC_DIR=""
|
||||||
for DIR in src/ htdocs/ lib/; do
|
for DIR in source/ src/ htdocs/ lib/; do
|
||||||
if [ -d "$DIR" ]; then
|
if [ -d "$DIR" ]; then
|
||||||
SRC_DIR="$DIR"
|
SRC_DIR="$DIR"
|
||||||
break
|
break
|
||||||
@@ -435,7 +435,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [ -z "$SRC_DIR" ]; then
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -159,11 +159,11 @@ jobs:
|
|||||||
echo "::error file=${file}::Missing JEXEC guard: ${file}"
|
echo "::error file=${file}::Missing JEXEC guard: ${file}"
|
||||||
ERRORS=$((ERRORS + 1))
|
ERRORS=$((ERRORS + 1))
|
||||||
fi
|
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
|
if [ "$ERRORS" -gt 0 ]; then
|
||||||
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
|
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
|
||||||
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "JEXEC guard: OK"
|
echo "JEXEC guard: OK"
|
||||||
@@ -172,7 +172,8 @@ jobs:
|
|||||||
if: steps.platform.outputs.platform == 'joomla'
|
if: steps.platform.outputs.platform == 'joomla'
|
||||||
run: |
|
run: |
|
||||||
MISSING=0
|
MISSING=0
|
||||||
SOURCE_DIR="src"
|
SOURCE_DIR="source"
|
||||||
|
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="src"
|
||||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||||
while IFS= read -r dir; do
|
while IFS= read -r dir; do
|
||||||
if [ ! -f "${dir}/index.html" ]; then
|
if [ ! -f "${dir}/index.html" ]; then
|
||||||
@@ -220,7 +221,7 @@ jobs:
|
|||||||
echo "joomla.asset.json: valid"
|
echo "joomla.asset.json: valid"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Validate all XML files in src/ are well-formed
|
# Validate all XML files in source/src are well-formed
|
||||||
XML_ERRORS=0
|
XML_ERRORS=0
|
||||||
if command -v php &> /dev/null; then
|
if command -v php &> /dev/null; then
|
||||||
while IFS= read -r -d '' xmlfile; do
|
while IFS= read -r -d '' xmlfile; do
|
||||||
@@ -451,10 +452,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Verify package source
|
- name: Verify package source
|
||||||
run: |
|
run: |
|
||||||
SOURCE_DIR="src"
|
SOURCE_DIR="source"
|
||||||
|
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="src"
|
||||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||||
if [ ! -d "$SOURCE_DIR" ]; then
|
if [ ! -d "$SOURCE_DIR" ]; then
|
||||||
echo "::warning::No src/ or htdocs/ directory"
|
echo "::warning::No source/, src/, or htdocs/ directory"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||||
|
|||||||
@@ -63,15 +63,22 @@ jobs:
|
|||||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||||
run: |
|
run: |
|
||||||
if ! command -v composer &> /dev/null; then
|
# Use pre-installed /opt/moko-platform if available (updated by cron every 6h)
|
||||||
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; 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"
|
||||||
fi
|
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
|
- name: Detect platform
|
||||||
id: platform
|
id: platform
|
||||||
|
|||||||
@@ -296,17 +296,19 @@ jobs:
|
|||||||
missing_required=()
|
missing_required=()
|
||||||
missing_optional=()
|
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=""
|
SOURCE_DIR=""
|
||||||
if [ -d "src" ]; then
|
if [ -d "source" ]; then
|
||||||
|
SOURCE_DIR="source"
|
||||||
|
elif [ -d "src" ]; then
|
||||||
SOURCE_DIR="src"
|
SOURCE_DIR="src"
|
||||||
elif [ -d "htdocs" ]; then
|
elif [ -d "htdocs" ]; then
|
||||||
SOURCE_DIR="htdocs"
|
SOURCE_DIR="htdocs"
|
||||||
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
|
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
|
||||||
# Platform/tooling repos don't need src/
|
# Platform/tooling repos don't need source/
|
||||||
SOURCE_DIR=""
|
SOURCE_DIR=""
|
||||||
else
|
else
|
||||||
missing_required+=("src/ or htdocs/ (source directory required)")
|
missing_required+=("source/ or src/ or htdocs/ (source directory required)")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for item in "${required_artifacts[@]}"; do
|
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.
|
# Options: system, content, user, authentication, etc.
|
||||||
|
|
||||||
# Directories
|
# Directories
|
||||||
SRC_DIR := src
|
SRC_DIR := source
|
||||||
BUILD_DIR := build
|
BUILD_DIR := build
|
||||||
DIST_DIR := dist
|
DIST_DIR := dist
|
||||||
DOCS_DIR := docs
|
DOCS_DIR := docs
|
||||||
|
|||||||
+2
@@ -27,6 +27,8 @@ class DashboardController extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function migrate(): void
|
public function migrate(): void
|
||||||
{
|
{
|
||||||
|
$this->checkToken();
|
||||||
|
|
||||||
// Check ACL
|
// Check ACL
|
||||||
if (!$this->app->getIdentity()->authorise('mokojoomcross.migrate', 'com_mokojoomcross')) {
|
if (!$this->app->getIdentity()->authorise('mokojoomcross.migrate', 'com_mokojoomcross')) {
|
||||||
$this->setRedirect(
|
$this->setRedirect(
|
||||||
+7
@@ -55,6 +55,13 @@ class DispatchController extends BaseController
|
|||||||
return;
|
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
|
// Read JSON body
|
||||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
$articleId = (int) ($input['article_id'] ?? 0);
|
$articleId = (int) ($input['article_id'] ?? 0);
|
||||||
+2
@@ -36,6 +36,8 @@ class OauthController extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function authorize(): void
|
public function authorize(): void
|
||||||
{
|
{
|
||||||
|
$this->checkToken();
|
||||||
|
|
||||||
$serviceId = $this->input->getInt('service_id', 0);
|
$serviceId = $this->input->getInt('service_id', 0);
|
||||||
|
|
||||||
if (!$serviceId) {
|
if (!$serviceId) {
|
||||||
+10
-1
@@ -76,7 +76,10 @@ class PostsController extends AdminController
|
|||||||
->set($db->quoteName('scheduled_at') . ' = ' . $db->quote($scheduledAt))
|
->set($db->quoteName('scheduled_at') . ' = ' . $db->quote($scheduledAt))
|
||||||
->set($db->quoteName('status') . ' = ' . $db->quote('queued'))
|
->set($db->quoteName('status') . ' = ' . $db->quote('queued'))
|
||||||
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
|
->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->setQuery($query);
|
||||||
$db->execute();
|
$db->execute();
|
||||||
@@ -156,6 +159,12 @@ class PostsController extends AdminController
|
|||||||
*/
|
*/
|
||||||
public function exportCsv(): void
|
public function exportCsv(): void
|
||||||
{
|
{
|
||||||
|
$this->checkToken('get');
|
||||||
|
|
||||||
|
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokojoomcross')) {
|
||||||
|
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||||
|
}
|
||||||
|
|
||||||
$app = $this->app;
|
$app = $this->app;
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
+17
-6
@@ -29,6 +29,12 @@ class ServiceController extends FormController
|
|||||||
*/
|
*/
|
||||||
public function testConnection(): void
|
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;
|
$app = $this->app;
|
||||||
$id = (int) $this->input->getInt('id', 0);
|
$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'));
|
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');
|
PluginHelper::importPlugin('mokojoomcross');
|
||||||
|
|
||||||
$servicePlugins = [];
|
$servicePlugins = [];
|
||||||
$app->getDispatcher()->dispatch(
|
$event = new \Joomla\Event\Event('onMokoJoomCrossGetServices', [$servicePlugins]);
|
||||||
'onMokoJoomCrossGetServices',
|
$app->getDispatcher()->dispatch('onMokoJoomCrossGetServices', $event);
|
||||||
new \Joomla\Event\Event('onMokoJoomCrossGetServices', [&$servicePlugins])
|
|
||||||
);
|
$idx = 1;
|
||||||
|
|
||||||
|
while (isset($event[$idx])) {
|
||||||
|
$servicePlugins[] = $event[$idx];
|
||||||
|
$idx++;
|
||||||
|
}
|
||||||
|
|
||||||
// Find the matching plugin
|
// Find the matching plugin
|
||||||
$plugin = null;
|
$plugin = null;
|
||||||
@@ -74,7 +85,7 @@ class ServiceController extends FormController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decode credentials and validate
|
// Decode credentials and validate
|
||||||
$credentials = json_decode($service->credentials ?: '{}', true) ?: [];
|
$credentials = \Joomla\Component\MokoJoomCross\Administrator\Helper\CredentialHelper::decrypt($service->credentials ?: '');
|
||||||
$result = $plugin->validateCredentials($credentials);
|
$result = $plugin->validateCredentials($credentials);
|
||||||
|
|
||||||
$app->mimeType = 'application/json';
|
$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;
|
$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) {
|
foreach ($services as $service) {
|
||||||
// Category routing filter — if rules exist, only post to whitelisted services
|
// Category routing filter — if rules exist, only post to whitelisted services
|
||||||
if ($categoryServiceIds !== null && !in_array((int) $service->id, $categoryServiceIds, true)) {
|
if ($categoryServiceIds !== null && !in_array((int) $service->id, $categoryServiceIds, true)) {
|
||||||
@@ -174,7 +177,7 @@ class CrossPostDispatcher
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$message = self::renderTemplate($article, $service, $templateMap);
|
$message = self::renderTemplate($article, $service, $templateMap, $articleMeta);
|
||||||
|
|
||||||
// Extract intro image for media attachment
|
// Extract intro image for media attachment
|
||||||
$media = [];
|
$media = [];
|
||||||
@@ -236,7 +239,7 @@ class CrossPostDispatcher
|
|||||||
);
|
);
|
||||||
$db->execute();
|
$db->execute();
|
||||||
|
|
||||||
$credentials = json_decode($service->credentials ?: '{}', true) ?: [];
|
$credentials = CredentialHelper::decrypt($service->credentials ?: '');
|
||||||
$params = json_decode($service->params ?: '{}', true) ?: [];
|
$params = json_decode($service->params ?: '{}', true) ?: [];
|
||||||
|
|
||||||
if (!empty($articleUrl)) {
|
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();
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
@@ -367,77 +454,8 @@ class CrossPostDispatcher
|
|||||||
$template = $db->loadResult() ?: "{title}\n\n{url}";
|
$template = $db->loadResult() ?: "{title}\n\n{url}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build SEF article URL
|
// Use pre-built metadata if available, otherwise build on the fly
|
||||||
$url = $article->_article_url
|
$replacements = !empty($articleMeta) ? $articleMeta : self::buildArticleMeta($article);
|
||||||
?? (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,
|
|
||||||
];
|
|
||||||
|
|
||||||
$message = str_replace(array_keys($replacements), array_values($replacements), $template);
|
$message = str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||||
|
|
||||||
+1
-1
@@ -188,7 +188,7 @@ class OAuthHelper
|
|||||||
|
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->update($db->quoteName('#__mokojoomcross_services'))
|
->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()))
|
->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql()))
|
||||||
->where($db->quoteName('id') . ' = ' . $serviceId);
|
->where($db->quoteName('id') . ' = ' . $serviceId);
|
||||||
|
|
||||||
+83
-40
@@ -17,6 +17,7 @@ use Joomla\CMS\Component\ComponentHelper;
|
|||||||
use Joomla\CMS\Factory;
|
use Joomla\CMS\Factory;
|
||||||
use Joomla\CMS\Plugin\PluginHelper;
|
use Joomla\CMS\Plugin\PluginHelper;
|
||||||
use Joomla\CMS\Uri\Uri;
|
use Joomla\CMS\Uri\Uri;
|
||||||
|
use Joomla\Component\MokoJoomCross\Administrator\Helper\CredentialHelper;
|
||||||
use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface;
|
use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,7 +147,7 @@ class QueueProcessor
|
|||||||
);
|
);
|
||||||
$db->execute();
|
$db->execute();
|
||||||
|
|
||||||
$credentials = json_decode($post->credentials ?: '{}', true) ?: [];
|
$credentials = CredentialHelper::decrypt($post->credentials ?: '');
|
||||||
$params = json_decode($post->service_params ?: '{}', true) ?: [];
|
$params = json_decode($post->service_params ?: '{}', true) ?: [];
|
||||||
|
|
||||||
// Token auto-refresh before posting
|
// Token auto-refresh before posting
|
||||||
@@ -346,6 +347,40 @@ class QueueProcessor
|
|||||||
// they are loaded in case any lifecycle events depend on them)
|
// they are loaded in case any lifecycle events depend on them)
|
||||||
PluginHelper::importPlugin('mokojoomcross');
|
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) {
|
foreach ($articles as $article) {
|
||||||
if ($result['queued'] >= $maxPerRun) {
|
if ($result['queued'] >= $maxPerRun) {
|
||||||
break;
|
break;
|
||||||
@@ -380,18 +415,10 @@ class QueueProcessor
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check last successful post for this article+service
|
$key = $article->id . ':' . $service->id;
|
||||||
$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);
|
|
||||||
|
|
||||||
$db->setQuery($query);
|
// Check last successful post from batch-loaded map
|
||||||
$lastPosted = $db->loadResult();
|
$lastPosted = $lastPostedMap[$key] ?? null;
|
||||||
|
|
||||||
if (empty($lastPosted)) {
|
if (empty($lastPosted)) {
|
||||||
// Never posted — skip, the initial cross-post will handle it
|
// Never posted — skip, the initial cross-post will handle it
|
||||||
@@ -399,25 +426,14 @@ class QueueProcessor
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if interval has elapsed
|
// 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()) {
|
if ($dueDate->toUnix() > Factory::getDate()->toUnix()) {
|
||||||
// Not due yet
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if there's already a queued/posting entry
|
// Skip if there's already a queued/posting entry
|
||||||
$query = $db->getQuery(true)
|
if (isset($pendingSet[$key])) {
|
||||||
->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) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,8 +658,7 @@ class QueueProcessor
|
|||||||
$componentParams = ComponentHelper::getParams('com_mokojoomcross');
|
$componentParams = ComponentHelper::getParams('com_mokojoomcross');
|
||||||
$maxRetry = (int) $componentParams->get('retry_max', 3);
|
$maxRetry = (int) $componentParams->get('retry_max', 3);
|
||||||
$retryDelay = (int) $componentParams->get('retry_delay', 300);
|
$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
|
// Queued posts ready to go
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
@@ -655,13 +670,14 @@ class QueueProcessor
|
|||||||
$db->setQuery($query);
|
$db->setQuery($query);
|
||||||
$queued = (int) $db->loadResult();
|
$queued = (int) $db->loadResult();
|
||||||
|
|
||||||
// Failed posts eligible for retry
|
// Failed posts eligible for retry (exponential backoff matching processQueue)
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select('COUNT(*)')
|
->select('COUNT(*)')
|
||||||
->from($db->quoteName('#__mokojoomcross_posts'))
|
->from($db->quoteName('#__mokojoomcross_posts'))
|
||||||
->where($db->quoteName('status') . ' = ' . $db->quote('failed'))
|
->where($db->quoteName('status') . ' = ' . $db->quote('failed'))
|
||||||
->where($db->quoteName('retry_count') . ' < ' . $maxRetry)
|
->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);
|
$db->setQuery($query);
|
||||||
$retryable = (int) $db->loadResult();
|
$retryable = (int) $db->loadResult();
|
||||||
|
|
||||||
@@ -796,31 +812,58 @@ class QueueProcessor
|
|||||||
/**
|
/**
|
||||||
* Timestamp-based lock fallback for databases without advisory locks.
|
* Timestamp-based lock fallback for databases without advisory locks.
|
||||||
*
|
*
|
||||||
* Uses the component params to store a lock timestamp. Considers the lock
|
* Uses an atomic UPDATE with a WHERE clause to prevent TOCTOU race
|
||||||
* stale after 120 seconds to prevent deadlocks from crashed processes.
|
* conditions. The lock is considered stale after 120 seconds.
|
||||||
*/
|
*/
|
||||||
private static function acquireTimestampLock($db): bool
|
private static function acquireTimestampLock($db): bool
|
||||||
{
|
{
|
||||||
$params = ComponentHelper::getParams('com_mokojoomcross');
|
|
||||||
$lockTime = (int) $params->get('queue_lock_time', 0);
|
|
||||||
$now = time();
|
$now = time();
|
||||||
|
$staleThreshold = $now - 120;
|
||||||
|
|
||||||
if ($lockTime > 0 && ($now - $lockTime) < 120) {
|
// Atomic: only succeeds if lock is absent (0) or stale
|
||||||
return false;
|
$params = ComponentHelper::getParams('com_mokojoomcross');
|
||||||
}
|
$oldParams = $params->toString();
|
||||||
|
|
||||||
$params->set('queue_lock_time', $now);
|
$params->set('queue_lock_time', $now);
|
||||||
|
$newParams = $params->toString();
|
||||||
|
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->update($db->quoteName('#__extensions'))
|
->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('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->setQuery($query);
|
||||||
$db->execute();
|
$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();
|
$data = $this->getItem();
|
||||||
|
|
||||||
if ($data && !empty($data->credentials)) {
|
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 ?? '';
|
$serviceType = $data->service_type ?? '';
|
||||||
|
|
||||||
foreach ($credentials as $key => $value) {
|
foreach ($credentials as $key => $value) {
|
||||||
@@ -106,8 +106,10 @@ class ServiceModel extends AdminModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the credentials JSON
|
// Store credentials encrypted
|
||||||
$data['credentials'] = !empty($credentials) ? json_encode($credentials) : '{}';
|
$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
|
// Remove individual cred_* fields so they don't cause column-not-found errors
|
||||||
foreach (array_keys($data) as $key) {
|
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