Compare commits

...

7 Commits

Author SHA1 Message Date
Jonathan Miller 878a9b3726 feat: resolve 6 enhancement issues (#116-#119, #124, #125)
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
- #116: Batch N+1 queries in processEvergreen() — pre-load
  posted_at and pending status in 2 queries instead of N*M
- #117: Extract buildArticleMeta() from renderTemplate() — category,
  author, tags resolved once per article instead of per service
- #118: Wire up media attachments in Threads, WordPress, Medium,
  Tumblr, Teams, Google Business, Pinterest, TikTok
- #119: Rewrite 7 stub plugins with correct API implementations:
  Dev.to (api-key header), Brevo (api-key header, campaign format),
  ConvertKit (api_secret body), Reddit (form-encoded, subreddit),
  Pinterest (v5 pins with media_source), SendGrid (single sends),
  Constant Contact (email campaigns), TikTok (content init)
- #124: Teams — migrate to Adaptive Cards format, remove dead
  resolveCredential() method and duplicate webhook_url fallback
- #125: Google Business — fix URL path segments (accounts/locations)
  and add media attachment support

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 09:56:37 -05:00
Jonathan Miller 5df8b0fc38 fix: resolve remaining low-priority bugs (#121, #122, #123, #126)
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
- #121: schedule() now only allows re-scheduling posts with status
  queued/failed/permanently_failed/cancelled — prevents duplicates
- #122: updateLastRunTimestamp() uses JSON_SET for atomic update
  with fallback for databases without JSON function support
- #123: Add curl_error() handling to all 32 service plugins — DNS
  failures, SSL errors, and timeouts now return actionable messages
- #126: Fix Ntfy supportsMedia() to return false (consistent with
  empty getSupportedMediaTypes())

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 09:41:56 -05:00
Jonathan Miller 9484d6bde9 security: fix 9 security and critical bugs (#107-#115, #120)
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
- #107: Fix testConnection() broken event dispatch (Joomla 5+
  ArrayAccess pattern) and add CSRF + ACL checks
- #108: Add CSRF checkToken() to OauthController::authorize()
- #109: Add core.manage ACL check to REST dispatch endpoint
- #110: Fix LinkedIn null-coalesce on organization_id
- #111: Add CURLOPT_PROTOCOLS to webhook, mastodon, ghost, bluesky
  to prevent SSRF via user-controlled URLs
- #112: Encrypt credentials at rest using sodium_crypto_secretbox
  with key derived from Joomla secret; backward-compat with
  existing plaintext JSON credentials
- #113: Fix unclosed <script> tag in dashboard template
- #114: Fix hasPendingWork() to use exponential backoff matching
  processQueue() instead of linear delay
- #115: Fix timestamp lock TOCTOU race with atomic UPDATE + WHERE
- #120: Add CSRF token to dashboard migration link

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 09:33:12 -05:00
Jonathan Miller 5407b712f1 chore: move CLAUDE.md to .mokogitea/ directory
Relocate CLAUDE.md from repo root to .mokogitea/ per project convention.
Content updated with focused, repo-specific architecture and rules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 09:30:50 -05:00
Jonathan Miller 75c34345f9 refactor: rename src/ to source/ per moko-platform standards
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Rename root source directory from src/ to source/ and update all
references in Makefile, manifest.xml, .gitignore, CI workflows,
and wiki documentation. Internal Joomla namespace paths (src/Extension)
are unchanged as they are plugin-internal structure.

CI workflows updated to check source/ first with src/ fallback for
backward compatibility across repos.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 08:11:29 -05:00
jmiller 48d49b3ee0 chore: sync .mokogitea/workflows/pre-release.yml from moko-platform [skip ci] 2026-06-06 12:32:21 +00:00
Jonathan Miller 3f63ec2e1d feat(licensing): add licensing block to manifest and pre-release step
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Add <licensing> section to manifest.xml with update-server URL
template and dlid flag. Add manifest_licensing.php step to
pre-release workflow to auto-ensure updateservers/dlid tags.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 07:18:05 -05:00
607 changed files with 1475 additions and 406 deletions
+1 -1
View File
@@ -154,7 +154,7 @@ package-lock.json
# PHP / Composer tooling
# ============================================================
vendor/
!src/media/vendor/
!source/media/vendor/
composer.lock
*.phar
codeception.phar
+83
View File
@@ -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`
+6 -1
View File
@@ -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>
+6 -6
View File
@@ -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
+8 -6
View File
@@ -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)
+15 -8
View File
@@ -63,15 +63,22 @@ 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
# 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/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
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
+6 -4
View File
@@ -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
-112
View File
@@ -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`
+1 -1
View File
@@ -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
@@ -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);
@@ -36,6 +36,8 @@ class OauthController extends BaseController
*/
public function authorize(): void
{
$this->checkToken();
$serviceId = $this->input->getInt('service_id', 0);
if (!$serviceId) {
@@ -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();
@@ -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);
}
}
@@ -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);
@@ -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);
@@ -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;
}
/**
@@ -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