feat: rewrite content sync to use Joomla REST API with per-task config
Each sync task instance now has its own target URL, API token, and content type toggles. Sync strategy is delete-then-push via the Joomla API for articles and menus (avoids duplicates, respects ACL). Content types: articles, categories, menus, modules File types: images/, files/, media/ (via MokoWaaS sync-receive endpoint) Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<form>
|
||||
<fieldset name="sync_params" label="Content Sync Settings">
|
||||
<field name="sync_info" type="note"
|
||||
label="Sync Targets"
|
||||
description="Content sync targets are configured in the MokoWaaS system plugin settings (Content Sync tab). This task will push content to all configured targets on each execution." />
|
||||
<fieldset name="sync_params" label="PLG_TASK_MOKOWAASSYNC_FIELDSET_TARGET">
|
||||
<field name="target_url" type="url"
|
||||
label="PLG_TASK_MOKOWAASSYNC_TARGET_URL_LABEL"
|
||||
description="PLG_TASK_MOKOWAASSYNC_TARGET_URL_DESC"
|
||||
required="true"
|
||||
hint="https://demo.example.com" />
|
||||
<field name="api_token" type="text"
|
||||
label="PLG_TASK_MOKOWAASSYNC_API_TOKEN_LABEL"
|
||||
description="PLG_TASK_MOKOWAASSYNC_API_TOKEN_DESC"
|
||||
required="true"
|
||||
hint="Joomla API token for the target site" />
|
||||
<field name="api_user" type="text"
|
||||
label="PLG_TASK_MOKOWAASSYNC_API_USER_LABEL"
|
||||
description="PLG_TASK_MOKOWAASSYNC_API_USER_DESC"
|
||||
default=""
|
||||
hint="Optional — API user on the target site" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="sync_content_types" label="PLG_TASK_MOKOWAASSYNC_FIELDSET_CONTENT">
|
||||
<field name="sync_articles" type="radio" default="1"
|
||||
label="PLG_TASK_MOKOWAASSYNC_SYNC_ARTICLES_LABEL"
|
||||
description="PLG_TASK_MOKOWAASSYNC_SYNC_ARTICLES_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="sync_categories" type="radio" default="1"
|
||||
label="PLG_TASK_MOKOWAASSYNC_SYNC_CATEGORIES_LABEL"
|
||||
description="PLG_TASK_MOKOWAASSYNC_SYNC_CATEGORIES_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="sync_menus" type="radio" default="1"
|
||||
label="PLG_TASK_MOKOWAASSYNC_SYNC_MENUS_LABEL"
|
||||
description="PLG_TASK_MOKOWAASSYNC_SYNC_MENUS_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="sync_modules" type="radio" default="0"
|
||||
label="PLG_TASK_MOKOWAASSYNC_SYNC_MODULES_LABEL"
|
||||
description="PLG_TASK_MOKOWAASSYNC_SYNC_MODULES_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="sync_files" label="PLG_TASK_MOKOWAASSYNC_FIELDSET_FILES">
|
||||
<field name="sync_images" type="radio" default="1"
|
||||
label="PLG_TASK_MOKOWAASSYNC_SYNC_IMAGES_LABEL"
|
||||
description="PLG_TASK_MOKOWAASSYNC_SYNC_IMAGES_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="sync_files" type="radio" default="0"
|
||||
label="PLG_TASK_MOKOWAASSYNC_SYNC_FILES_LABEL"
|
||||
description="PLG_TASK_MOKOWAASSYNC_SYNC_FILES_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="sync_media" type="radio" default="0"
|
||||
label="PLG_TASK_MOKOWAASSYNC_SYNC_MEDIA_LABEL"
|
||||
description="PLG_TASK_MOKOWAASSYNC_SYNC_MEDIA_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -3,6 +3,35 @@
|
||||
; SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
PLG_TASK_MOKOWAASSYNC="Task - MokoWaaS Content Sync"
|
||||
PLG_TASK_MOKOWAASSYNC_DESC="Scheduled task to push content (articles, categories, menus, modules) to remote MokoWaaS sites."
|
||||
PLG_TASK_MOKOWAASSYNC_DESC="Scheduled task to sync content to a remote MokoWaaS site via the Joomla API. Each task instance syncs to one target."
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_TITLE="MokoWaaS Content Sync"
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_DESC="Push site content to all configured sync targets. Targets are configured in the MokoWaaS system plugin settings."
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_DESC="Sync selected content types to a single remote target site via the Joomla REST API."
|
||||
|
||||
; ===== Target fieldset =====
|
||||
PLG_TASK_MOKOWAASSYNC_FIELDSET_TARGET="Sync Target"
|
||||
PLG_TASK_MOKOWAASSYNC_TARGET_URL_LABEL="Target Site URL"
|
||||
PLG_TASK_MOKOWAASSYNC_TARGET_URL_DESC="Base URL of the remote Joomla site to sync to (e.g. https://demo.example.com)."
|
||||
PLG_TASK_MOKOWAASSYNC_API_TOKEN_LABEL="API Token"
|
||||
PLG_TASK_MOKOWAASSYNC_API_TOKEN_DESC="Joomla API token (Bearer token) for authenticating with the target site's REST API."
|
||||
PLG_TASK_MOKOWAASSYNC_API_USER_LABEL="API User"
|
||||
PLG_TASK_MOKOWAASSYNC_API_USER_DESC="Optional username on the target site. Used for logging purposes only."
|
||||
|
||||
; ===== Content types fieldset =====
|
||||
PLG_TASK_MOKOWAASSYNC_FIELDSET_CONTENT="Content to Sync"
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_ARTICLES_LABEL="Articles"
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_ARTICLES_DESC="Sync articles (com_content). Deletes all articles on the target, then pushes exact copies from this site."
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_CATEGORIES_LABEL="Categories"
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_CATEGORIES_DESC="Sync content categories. Ensures category structure matches this site."
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_MENUS_LABEL="Menus"
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_MENUS_DESC="Sync menu items. Deletes all menu items on the target, then pushes exact copies from this site."
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_MODULES_LABEL="Modules"
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_MODULES_DESC="Sync site modules. Pushes module configuration and assignments."
|
||||
|
||||
; ===== Files fieldset =====
|
||||
PLG_TASK_MOKOWAASSYNC_FIELDSET_FILES="Files to Sync"
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_IMAGES_LABEL="Images (/images/)"
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_IMAGES_DESC="Sync the /images/ directory to the target site."
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_FILES_LABEL="Files (/files/)"
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_FILES_DESC="Sync the /files/ directory to the target site."
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_MEDIA_LABEL="Media (/media/)"
|
||||
PLG_TASK_MOKOWAASSYNC_SYNC_MEDIA_DESC="Sync the /media/ directory to the target site. Be careful — this includes extension assets."
|
||||
|
||||
@@ -20,11 +20,16 @@ use Joomla\Event\SubscriberInterface;
|
||||
/**
|
||||
* MokoWaaS Content Sync — Joomla Scheduled Task Plugin.
|
||||
*
|
||||
* Pushes site content (articles, categories, menus, modules) to
|
||||
* configured remote MokoWaaS sites on a schedule. Sync targets are
|
||||
* read from the system plugin params (content_sync fieldset).
|
||||
* Syncs selected content types to a single remote Joomla site via the
|
||||
* REST API. Each task instance has its own target URL, API token, and
|
||||
* content type toggles — so multiple targets can sync independently
|
||||
* on different schedules.
|
||||
*
|
||||
* @since 02.27.00
|
||||
* Sync strategy: delete-then-push for articles and menus to avoid
|
||||
* duplicates. Categories are upserted. Files are pushed via the
|
||||
* MokoWaaS sync-receive endpoint.
|
||||
*
|
||||
* @since 02.30.00
|
||||
*/
|
||||
final class ContentSync extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
@@ -48,117 +53,559 @@ final class ContentSync extends CMSPlugin implements SubscriberInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Push content to all configured sync targets.
|
||||
*
|
||||
* Reads sync_targets from the MokoWaaS system plugin params, then
|
||||
* delegates to ContentSyncService. Task-level overrides (if any)
|
||||
* are merged on top.
|
||||
* Sync content to the configured target.
|
||||
*
|
||||
* @param ExecuteTaskEvent $event The task event
|
||||
*
|
||||
* @return int Status::OK or Status::KNOCKOUT
|
||||
*
|
||||
* @since 02.27.00
|
||||
* @since 02.30.00
|
||||
*/
|
||||
private function syncContent(ExecuteTaskEvent $event): int
|
||||
{
|
||||
$serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/ContentSyncService.php';
|
||||
$params = $event->getArgument('params');
|
||||
|
||||
if (!file_exists($serviceFile))
|
||||
$targetUrl = rtrim($params->target_url ?? '', '/');
|
||||
$apiToken = $params->api_token ?? '';
|
||||
|
||||
if (empty($targetUrl) || empty($apiToken))
|
||||
{
|
||||
$this->logTask('ContentSyncService.php not found — is plg_system_mokowaas installed?');
|
||||
$this->logTask('Sync target URL or API token not configured');
|
||||
|
||||
return Status::KNOCKOUT;
|
||||
}
|
||||
|
||||
require_once $serviceFile;
|
||||
$errors = 0;
|
||||
$synced = [];
|
||||
$apiBase = $targetUrl . '/api/index.php/v1';
|
||||
|
||||
// Read sync targets from the system plugin params
|
||||
$targets = $this->getSyncTargets();
|
||||
|
||||
if (empty($targets))
|
||||
// Sync content types based on toggles
|
||||
if ((int) ($params->sync_categories ?? 0) === 1)
|
||||
{
|
||||
$this->logTask('No sync targets configured in MokoWaaS system plugin');
|
||||
$result = $this->syncCategories($apiBase, $apiToken);
|
||||
$synced[] = 'categories:' . ($result ? 'ok' : 'fail');
|
||||
if (!$result) $errors++;
|
||||
}
|
||||
|
||||
if ((int) ($params->sync_articles ?? 0) === 1)
|
||||
{
|
||||
$result = $this->syncArticles($apiBase, $apiToken);
|
||||
$synced[] = 'articles:' . ($result ? 'ok' : 'fail');
|
||||
if (!$result) $errors++;
|
||||
}
|
||||
|
||||
if ((int) ($params->sync_menus ?? 0) === 1)
|
||||
{
|
||||
$result = $this->syncMenus($apiBase, $apiToken);
|
||||
$synced[] = 'menus:' . ($result ? 'ok' : 'fail');
|
||||
if (!$result) $errors++;
|
||||
}
|
||||
|
||||
if ((int) ($params->sync_modules ?? 0) === 1)
|
||||
{
|
||||
$result = $this->syncModules($apiBase, $apiToken);
|
||||
$synced[] = 'modules:' . ($result ? 'ok' : 'fail');
|
||||
if (!$result) $errors++;
|
||||
}
|
||||
|
||||
// File sync via MokoWaaS endpoint
|
||||
$healthToken = $this->getHealthToken();
|
||||
|
||||
if ((int) ($params->sync_images ?? 0) === 1)
|
||||
{
|
||||
$result = $this->syncDirectory('images', $targetUrl, $healthToken);
|
||||
$synced[] = 'images:' . ($result ? 'ok' : 'fail');
|
||||
if (!$result) $errors++;
|
||||
}
|
||||
|
||||
if ((int) ($params->sync_files ?? 0) === 1)
|
||||
{
|
||||
$result = $this->syncDirectory('files', $targetUrl, $healthToken);
|
||||
$synced[] = 'files:' . ($result ? 'ok' : 'fail');
|
||||
if (!$result) $errors++;
|
||||
}
|
||||
|
||||
if ((int) ($params->sync_media ?? 0) === 1)
|
||||
{
|
||||
$result = $this->syncDirectory('media', $targetUrl, $healthToken);
|
||||
$synced[] = 'media:' . ($result ? 'ok' : 'fail');
|
||||
if (!$result) $errors++;
|
||||
}
|
||||
|
||||
$summary = implode(', ', $synced);
|
||||
|
||||
if (empty($synced))
|
||||
{
|
||||
$this->logTask('No content types selected for sync');
|
||||
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
$this->logTask("Sync to {$targetUrl}: {$summary}");
|
||||
|
||||
return $errors > 0 && $errors === count($synced) ? Status::KNOCKOUT : Status::OK;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Joomla API sync methods
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sync articles: delete all on target, then push from source.
|
||||
*
|
||||
* @param string $apiBase Target API base URL
|
||||
* @param string $token API bearer token
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 02.30.00
|
||||
*/
|
||||
private function syncArticles(string $apiBase, string $token): bool
|
||||
{
|
||||
try
|
||||
{
|
||||
$service = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncService();
|
||||
$result = $service->syncAllTargets($targets);
|
||||
// Delete all articles on target
|
||||
$existing = $this->apiGet($apiBase . '/content/articles?page[limit]=0', $token);
|
||||
|
||||
$targetResults = $result['targets'] ?? [];
|
||||
$okCount = 0;
|
||||
$errCount = 0;
|
||||
|
||||
foreach ($targetResults as $tr)
|
||||
if ($existing !== null && !empty($existing->data))
|
||||
{
|
||||
if (($tr['status'] ?? '') === 'ok')
|
||||
foreach ($existing->data as $article)
|
||||
{
|
||||
$okCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
$errCount++;
|
||||
$this->logTask('Sync failed for ' . ($tr['target'] ?? 'unknown') . ': ' . ($tr['message'] ?? ''));
|
||||
$this->apiDelete($apiBase . '/content/articles/' . $article->attributes->id, $token);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logTask(sprintf('Content sync completed — %d ok, %d failed of %d target(s)', $okCount, $errCount, count($targetResults)));
|
||||
// Read all articles from source
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('state') . ' >= 0');
|
||||
$db->setQuery($query);
|
||||
$articles = $db->loadObjectList();
|
||||
|
||||
return $errCount > 0 && $okCount === 0 ? Status::KNOCKOUT : Status::OK;
|
||||
// Push each article to target
|
||||
foreach ($articles as $article)
|
||||
{
|
||||
$payload = [
|
||||
'title' => $article->title,
|
||||
'alias' => $article->alias,
|
||||
'articletext' => $article->introtext . ($article->fulltext ? '<hr id="system-readmore" />' . $article->fulltext : ''),
|
||||
'catid' => $article->catid,
|
||||
'state' => $article->state,
|
||||
'language' => $article->language,
|
||||
'featured' => $article->featured,
|
||||
'metadesc' => $article->metadesc ?? '',
|
||||
'metakey' => $article->metakey ?? '',
|
||||
];
|
||||
|
||||
$this->apiPost($apiBase . '/content/articles', $token, $payload);
|
||||
}
|
||||
|
||||
$this->logTask(sprintf('Synced %d articles', count($articles)));
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->logTask('Content sync failed: ' . $e->getMessage());
|
||||
$this->logTask('Article sync failed: ' . $e->getMessage());
|
||||
|
||||
return Status::KNOCKOUT;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read sync targets from the MokoWaaS system plugin configuration.
|
||||
* Sync categories: push from source, creating or updating on target.
|
||||
*
|
||||
* @return array Array of ['url' => ..., 'token' => ..., 'label' => ...]
|
||||
* @param string $apiBase Target API base URL
|
||||
* @param string $token API bearer token
|
||||
*
|
||||
* @since 02.27.00
|
||||
* @return bool
|
||||
*
|
||||
* @since 02.30.00
|
||||
*/
|
||||
private function getSyncTargets(): array
|
||||
private function syncCategories(string $apiBase, string $token): bool
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__categories'))
|
||||
->where($db->quoteName('extension') . ' = ' . $db->quote('com_content'))
|
||||
->where($db->quoteName('published') . ' >= 0')
|
||||
->where($db->quoteName('id') . ' > 1')
|
||||
->order($db->quoteName('lft') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$categories = $db->loadObjectList();
|
||||
|
||||
foreach ($categories as $cat)
|
||||
{
|
||||
$payload = [
|
||||
'title' => $cat->title,
|
||||
'alias' => $cat->alias,
|
||||
'description' => $cat->description ?? '',
|
||||
'published' => $cat->published,
|
||||
'language' => $cat->language,
|
||||
'extension' => 'com_content',
|
||||
];
|
||||
|
||||
$this->apiPost($apiBase . '/content/categories', $token, $payload);
|
||||
}
|
||||
|
||||
$this->logTask(sprintf('Synced %d categories', count($categories)));
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->logTask('Category sync failed: ' . $e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync menus: delete all non-system items on target, then push from source.
|
||||
*
|
||||
* @param string $apiBase Target API base URL
|
||||
* @param string $token API bearer token
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 02.30.00
|
||||
*/
|
||||
private function syncMenus(string $apiBase, string $token): bool
|
||||
{
|
||||
try
|
||||
{
|
||||
// Delete existing menu items on target
|
||||
$existing = $this->apiGet($apiBase . '/menus/site?page[limit]=0', $token);
|
||||
|
||||
if ($existing !== null && !empty($existing->data))
|
||||
{
|
||||
// Delete in reverse order (children first)
|
||||
$items = array_reverse((array) $existing->data);
|
||||
|
||||
foreach ($items as $item)
|
||||
{
|
||||
$this->apiDelete($apiBase . '/menus/site/' . $item->attributes->id, $token);
|
||||
}
|
||||
}
|
||||
|
||||
// Read menu items from source (site menus only, ordered by lft for tree)
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__menu'))
|
||||
->where($db->quoteName('client_id') . ' = 0')
|
||||
->where($db->quoteName('id') . ' > 1')
|
||||
->where($db->quoteName('published') . ' >= 0')
|
||||
->order($db->quoteName('lft') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$items = $db->loadObjectList();
|
||||
|
||||
foreach ($items as $item)
|
||||
{
|
||||
$payload = [
|
||||
'title' => $item->title,
|
||||
'alias' => $item->alias,
|
||||
'menutype' => $item->menutype,
|
||||
'type' => $item->type,
|
||||
'link' => $item->link,
|
||||
'language' => $item->language,
|
||||
'published' => $item->published,
|
||||
'home' => $item->home,
|
||||
];
|
||||
|
||||
$this->apiPost($apiBase . '/menus/site', $token, $payload);
|
||||
}
|
||||
|
||||
$this->logTask(sprintf('Synced %d menu items', count($items)));
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->logTask('Menu sync failed: ' . $e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync modules: push from source.
|
||||
*
|
||||
* @param string $apiBase Target API base URL
|
||||
* @param string $token API bearer token
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 02.30.00
|
||||
*/
|
||||
private function syncModules(string $apiBase, string $token): bool
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__modules'))
|
||||
->where($db->quoteName('client_id') . ' = 0')
|
||||
->where($db->quoteName('published') . ' >= 0');
|
||||
$db->setQuery($query);
|
||||
$modules = $db->loadObjectList();
|
||||
|
||||
foreach ($modules as $mod)
|
||||
{
|
||||
$payload = [
|
||||
'title' => $mod->title,
|
||||
'module' => $mod->module,
|
||||
'position' => $mod->position,
|
||||
'params' => $mod->params,
|
||||
'language' => $mod->language,
|
||||
'published' => $mod->published,
|
||||
];
|
||||
|
||||
$this->apiPost($apiBase . '/modules/site', $token, $payload);
|
||||
}
|
||||
|
||||
$this->logTask(sprintf('Synced %d modules', count($modules)));
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->logTask('Module sync failed: ' . $e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// File sync via MokoWaaS endpoint
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sync a directory to the target site via the MokoWaaS sync-receive endpoint.
|
||||
*
|
||||
* @param string $dir Directory name (images, files, media)
|
||||
* @param string $targetUrl Target site base URL
|
||||
* @param string $token MokoWaaS health token for auth
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 02.30.00
|
||||
*/
|
||||
private function syncDirectory(string $dir, string $targetUrl, string $token): bool
|
||||
{
|
||||
try
|
||||
{
|
||||
$sourcePath = JPATH_ROOT . '/' . $dir;
|
||||
|
||||
if (!is_dir($sourcePath))
|
||||
{
|
||||
$this->logTask("Directory /{$dir}/ does not exist — skipping");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Collect files
|
||||
$files = [];
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($sourcePath, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($iterator as $file)
|
||||
{
|
||||
if ($file->isFile())
|
||||
{
|
||||
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($sourcePath) + 1));
|
||||
$files[] = [
|
||||
'path' => $dir . '/' . $relativePath,
|
||||
'content' => base64_encode(file_get_contents($file->getPathname())),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($files))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Send in batches of 50 files
|
||||
$batches = array_chunk($files, 50);
|
||||
|
||||
foreach ($batches as $batch)
|
||||
{
|
||||
$payload = json_encode([
|
||||
'token' => $token,
|
||||
'files' => $batch,
|
||||
]);
|
||||
|
||||
$ch = curl_init($targetUrl . '/?mokowaas=syncreceive');
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300)
|
||||
{
|
||||
$this->logTask("File sync batch failed for /{$dir}/: HTTP {$httpCode}");
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$this->logTask(sprintf('Synced %d files from /%s/', count($files), $dir));
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->logTask("File sync failed for /{$dir}/: " . $e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET request to the Joomla API.
|
||||
*
|
||||
* @param string $url Full API URL
|
||||
* @param string $token Bearer token
|
||||
*
|
||||
* @return object|null Decoded JSON response
|
||||
*
|
||||
* @since 02.30.00
|
||||
*/
|
||||
private function apiGet(string $url, string $token): ?object
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: Bearer ' . $token,
|
||||
'Accept: application/vnd.api+json',
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode >= 200 && $httpCode < 300 && $response)
|
||||
{
|
||||
return json_decode($response);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request to the Joomla API.
|
||||
*
|
||||
* @param string $url Full API URL
|
||||
* @param string $token Bearer token
|
||||
* @param array $payload Data to send
|
||||
*
|
||||
* @return object|null Decoded JSON response
|
||||
*
|
||||
* @since 02.30.00
|
||||
*/
|
||||
private function apiPost(string $url, string $token, array $payload): ?object
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: Bearer ' . $token,
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/vnd.api+json',
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode >= 200 && $httpCode < 300 && $response)
|
||||
{
|
||||
return json_decode($response);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request to the Joomla API.
|
||||
*
|
||||
* @param string $url Full API URL
|
||||
* @param string $token Bearer token
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 02.30.00
|
||||
*/
|
||||
private function apiDelete(string $url, string $token): bool
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: Bearer ' . $token,
|
||||
'Accept: application/vnd.api+json',
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $httpCode >= 200 && $httpCode < 300;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read the MokoWaaS health API token from the system plugin params.
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
* @since 02.30.00
|
||||
*/
|
||||
private function getHealthToken(): string
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas'));
|
||||
|
||||
$db->setQuery($query);
|
||||
$raw = $db->loadResult();
|
||||
$params = json_decode($raw ?: '{}', true);
|
||||
|
||||
if (empty($raw))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$params = json_decode($raw, true) ?: [];
|
||||
$targets = $params['sync_targets'] ?? [];
|
||||
|
||||
if (is_string($targets))
|
||||
{
|
||||
$targets = json_decode($targets, true) ?: [];
|
||||
}
|
||||
|
||||
return is_array($targets) ? $targets : [];
|
||||
return $params['health_api_token'] ?? '';
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->logTask('Failed to read sync targets: ' . $e->getMessage());
|
||||
|
||||
return [];
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user