635 lines
16 KiB
PHP
635 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* @package MokoSuite
|
|
* @subpackage plg_system_mokosuite
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*
|
|
* FILE INFORMATION
|
|
* DEFGROUP: Joomla.Plugin
|
|
* INGROUP: MokoSuite
|
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
|
|
* PATH: /src/packages/plg_system_mokosuite/Service/ContentSyncService.php
|
|
* VERSION: 02.34.72
|
|
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
|
|
*/
|
|
|
|
namespace Moko\Plugin\Task\MokoSuiteSync\Service;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\Log\Log;
|
|
use Joomla\CMS\Uri\Uri;
|
|
|
|
/**
|
|
* Content Sync Service — builds a JSON payload of site content and pushes
|
|
* it to one or more remote MokoSuite sites.
|
|
*
|
|
* Content is matched by alias on the receiving end (upsert-by-alias).
|
|
* Category IDs in menu item links are encoded as {catid:alias/path} tokens
|
|
* so the receiver can resolve them to local IDs.
|
|
*
|
|
* @since 02.21.00
|
|
*/
|
|
class ContentSyncService
|
|
{
|
|
/**
|
|
* Maximum items per content type to prevent unbounded memory.
|
|
*
|
|
* @var int
|
|
* @since 02.21.00
|
|
*/
|
|
private const MAX_ITEMS = 2000;
|
|
|
|
/**
|
|
* HTTP timeout for push requests in seconds.
|
|
*
|
|
* @var int
|
|
* @since 02.21.00
|
|
*/
|
|
private const HTTP_TIMEOUT = 60;
|
|
|
|
/**
|
|
* @var \Joomla\Database\DatabaseInterface
|
|
* @since 02.21.00
|
|
*/
|
|
private $db;
|
|
|
|
/**
|
|
* Category ID → alias path map cache.
|
|
*
|
|
* @var array
|
|
* @since 02.21.00
|
|
*/
|
|
private array $catPathMap = [];
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @param \Joomla\Database\DatabaseInterface|null $db Database driver
|
|
*
|
|
* @since 02.21.00
|
|
*/
|
|
public function __construct($db = null)
|
|
{
|
|
$this->db = $db ?: Factory::getDbo();
|
|
}
|
|
|
|
/**
|
|
* Build the full sync payload from local content.
|
|
*
|
|
* @return array Structured payload ready for JSON encoding
|
|
*
|
|
* @since 02.21.00
|
|
*/
|
|
public function buildPayload(): array
|
|
{
|
|
$this->catPathMap = $this->buildCategoryPathMap();
|
|
|
|
return [
|
|
'mokosuite_sync' => '1.0',
|
|
'source_site' => rtrim(Uri::root(), '/'),
|
|
'generated_at' => gmdate('Y-m-d\TH:i:s\Z'),
|
|
'categories' => $this->buildCategoryPayload(),
|
|
'articles' => $this->buildArticlePayload(),
|
|
'menu_types' => $this->buildMenuTypePayload(),
|
|
'menu_items' => $this->buildMenuItemPayload(),
|
|
'modules' => $this->buildModulePayload(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Push the sync payload to a single target site.
|
|
*
|
|
* @param string $targetUrl Base URL of the target site
|
|
* @param string $token health_api_token for the target
|
|
*
|
|
* @return array Result with status, message, and per-type counts
|
|
*
|
|
* @since 02.21.00
|
|
*/
|
|
public function pushToTarget(string $targetUrl, string $token): array
|
|
{
|
|
$payload = $this->buildPayload();
|
|
$jsonBody = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
$endpoint = rtrim($targetUrl, '/') . '/?mokosuite=sync-receive';
|
|
|
|
$context = stream_context_create([
|
|
'http' => [
|
|
'method' => 'POST',
|
|
'header' => "Authorization: Bearer {$token}\r\nContent-Type: application/json\r\n",
|
|
'content' => $jsonBody,
|
|
'timeout' => self::HTTP_TIMEOUT,
|
|
'ignore_errors' => true,
|
|
],
|
|
'ssl' => [
|
|
'verify_peer' => false,
|
|
'verify_peer_name' => false,
|
|
],
|
|
]);
|
|
|
|
$response = @file_get_contents($endpoint, false, $context);
|
|
|
|
if ($response === false)
|
|
{
|
|
return [
|
|
'status' => 'error',
|
|
'target' => $targetUrl,
|
|
'message' => 'Connection failed — target unreachable',
|
|
];
|
|
}
|
|
|
|
// Parse HTTP status from response headers
|
|
$httpCode = 0;
|
|
|
|
if (isset($http_response_header[0]))
|
|
{
|
|
preg_match('/\d{3}/', $http_response_header[0], $matches);
|
|
$httpCode = (int) ($matches[0] ?? 0);
|
|
}
|
|
|
|
$result = json_decode($response, true);
|
|
|
|
if ($httpCode >= 400 || !$result)
|
|
{
|
|
return [
|
|
'status' => 'error',
|
|
'target' => $targetUrl,
|
|
'http_code' => $httpCode,
|
|
'message' => $result['error'] ?? $result['message'] ?? 'Unknown error from target',
|
|
];
|
|
}
|
|
|
|
$result['target'] = $targetUrl;
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Push content to all configured sync targets.
|
|
*
|
|
* @param array $targets Array of ['url' => ..., 'token' => ..., 'label' => ...]
|
|
*
|
|
* @return array Per-target results
|
|
*
|
|
* @since 02.21.00
|
|
*/
|
|
public function syncAllTargets(array $targets): array
|
|
{
|
|
$results = [];
|
|
|
|
foreach ($targets as $target)
|
|
{
|
|
$url = $target['url'] ?? '';
|
|
$token = $target['token'] ?? '';
|
|
$label = $target['label'] ?? $url;
|
|
|
|
if (empty($url) || empty($token))
|
|
{
|
|
$results[] = [
|
|
'status' => 'skipped',
|
|
'target' => $label,
|
|
'message' => 'Missing URL or token',
|
|
];
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
$result = $this->pushToTarget($url, $token);
|
|
$result['label'] = $label;
|
|
$results[] = $result;
|
|
}
|
|
catch (\Throwable $e)
|
|
{
|
|
$results[] = [
|
|
'status' => 'error',
|
|
'target' => $label,
|
|
'message' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
Log::add(
|
|
sprintf('Content sync pushed to %d target(s)', count($targets)),
|
|
Log::INFO,
|
|
'mokosuite'
|
|
);
|
|
|
|
return [
|
|
'status' => 'ok',
|
|
'message' => sprintf('Sync completed for %d target(s)', count($results)),
|
|
'targets' => $results,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build category ID → alias path map.
|
|
*
|
|
* @return array [id => 'parent-alias/child-alias']
|
|
*
|
|
* @since 02.21.00
|
|
*/
|
|
private function buildCategoryPathMap(): array
|
|
{
|
|
$db = $this->db;
|
|
$query = $db->getQuery(true)
|
|
->select([$db->quoteName('id'), $db->quoteName('alias'), $db->quoteName('parent_id')])
|
|
->from($db->quoteName('#__categories'))
|
|
->where($db->quoteName('extension') . ' = ' . $db->quote('com_content'))
|
|
->where($db->quoteName('published') . ' != -2')
|
|
->where($db->quoteName('id') . ' > 1');
|
|
|
|
$db->setQuery($query);
|
|
$rows = $db->loadAssocList('id');
|
|
|
|
$map = [];
|
|
|
|
foreach ($rows as $id => $row)
|
|
{
|
|
$map[$id] = $this->resolvePathFromRows($id, $rows);
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* Recursively build alias path for a category ID.
|
|
*
|
|
* @param int $id Category ID
|
|
* @param array $rows All category rows keyed by ID
|
|
*
|
|
* @return string Slash-delimited alias path
|
|
*
|
|
* @since 02.21.00
|
|
*/
|
|
private function resolvePathFromRows(int $id, array $rows): string
|
|
{
|
|
if (!isset($rows[$id]))
|
|
{
|
|
return '';
|
|
}
|
|
|
|
$row = $rows[$id];
|
|
$parentId = (int) $row['parent_id'];
|
|
|
|
if ($parentId <= 1 || !isset($rows[$parentId]))
|
|
{
|
|
return $row['alias'];
|
|
}
|
|
|
|
return $this->resolvePathFromRows($parentId, $rows) . '/' . $row['alias'];
|
|
}
|
|
|
|
/**
|
|
* Build category payload.
|
|
*
|
|
* @return array
|
|
*
|
|
* @since 02.21.00
|
|
*/
|
|
private function buildCategoryPayload(): array
|
|
{
|
|
$db = $this->db;
|
|
$query = $db->getQuery(true)
|
|
->select([
|
|
$db->quoteName('id'),
|
|
$db->quoteName('title'),
|
|
$db->quoteName('alias'),
|
|
$db->quoteName('description'),
|
|
$db->quoteName('published'),
|
|
$db->quoteName('access'),
|
|
$db->quoteName('language'),
|
|
$db->quoteName('params'),
|
|
$db->quoteName('metadata'),
|
|
])
|
|
->from($db->quoteName('#__categories'))
|
|
->where($db->quoteName('extension') . ' = ' . $db->quote('com_content'))
|
|
->where($db->quoteName('published') . ' != -2')
|
|
->where($db->quoteName('id') . ' > 1')
|
|
->order($db->quoteName('lft') . ' ASC')
|
|
->setLimit(self::MAX_ITEMS);
|
|
|
|
$db->setQuery($query);
|
|
$rows = $db->loadAssocList();
|
|
|
|
$categories = [];
|
|
|
|
foreach ($rows as $row)
|
|
{
|
|
$categories[] = [
|
|
'title' => $row['title'],
|
|
'alias' => $row['alias'],
|
|
'path' => $this->catPathMap[(int) $row['id']] ?? $row['alias'],
|
|
'description' => $row['description'] ?? '',
|
|
'published' => (int) $row['published'],
|
|
'access' => (int) $row['access'],
|
|
'language' => $row['language'],
|
|
'params' => json_decode($row['params'] ?: '{}', true),
|
|
'metadata' => json_decode($row['metadata'] ?: '{}', true),
|
|
];
|
|
}
|
|
|
|
return $categories;
|
|
}
|
|
|
|
/**
|
|
* Build article payload.
|
|
*
|
|
* @return array
|
|
*
|
|
* @since 02.21.00
|
|
*/
|
|
private function buildArticlePayload(): array
|
|
{
|
|
$db = $this->db;
|
|
$query = $db->getQuery(true)
|
|
->select([
|
|
$db->quoteName('title'),
|
|
$db->quoteName('alias'),
|
|
$db->quoteName('introtext'),
|
|
$db->quoteName('fulltext'),
|
|
$db->quoteName('state'),
|
|
$db->quoteName('catid'),
|
|
$db->quoteName('access'),
|
|
$db->quoteName('language'),
|
|
$db->quoteName('featured'),
|
|
$db->quoteName('publish_up'),
|
|
$db->quoteName('publish_down'),
|
|
$db->quoteName('metadata'),
|
|
$db->quoteName('attribs'),
|
|
$db->quoteName('images'),
|
|
$db->quoteName('urls'),
|
|
])
|
|
->from($db->quoteName('#__content'))
|
|
->where($db->quoteName('state') . ' != -2')
|
|
->order($db->quoteName('id') . ' ASC')
|
|
->setLimit(self::MAX_ITEMS);
|
|
|
|
$db->setQuery($query);
|
|
$rows = $db->loadAssocList();
|
|
|
|
$articles = [];
|
|
|
|
foreach ($rows as $row)
|
|
{
|
|
$articles[] = [
|
|
'title' => $row['title'],
|
|
'alias' => $row['alias'],
|
|
'introtext' => $row['introtext'],
|
|
'fulltext' => $row['fulltext'],
|
|
'state' => (int) $row['state'],
|
|
'catid_alias_path' => $this->catPathMap[(int) $row['catid']] ?? 'uncategorised',
|
|
'access' => (int) $row['access'],
|
|
'language' => $row['language'],
|
|
'featured' => (int) $row['featured'],
|
|
'publish_up' => $row['publish_up'],
|
|
'publish_down' => $row['publish_down'],
|
|
'metadata' => json_decode($row['metadata'] ?: '{}', true),
|
|
'attribs' => json_decode($row['attribs'] ?: '{}', true),
|
|
'images' => json_decode($row['images'] ?: '{}', true),
|
|
'urls' => json_decode($row['urls'] ?: '{}', true),
|
|
];
|
|
}
|
|
|
|
return $articles;
|
|
}
|
|
|
|
/**
|
|
* Build menu type payload.
|
|
*
|
|
* @return array
|
|
*
|
|
* @since 02.21.00
|
|
*/
|
|
private function buildMenuTypePayload(): array
|
|
{
|
|
$db = $this->db;
|
|
$query = $db->getQuery(true)
|
|
->select([
|
|
$db->quoteName('title'),
|
|
$db->quoteName('menutype'),
|
|
$db->quoteName('description'),
|
|
])
|
|
->from($db->quoteName('#__menu_types'));
|
|
|
|
$db->setQuery($query);
|
|
|
|
return $db->loadAssocList() ?: [];
|
|
}
|
|
|
|
/**
|
|
* Build menu item payload with {catid:path} tokens in links.
|
|
*
|
|
* @return array
|
|
*
|
|
* @since 02.21.00
|
|
*/
|
|
private function buildMenuItemPayload(): array
|
|
{
|
|
$db = $this->db;
|
|
$query = $db->getQuery(true)
|
|
->select([
|
|
$db->quoteName('a.title'),
|
|
$db->quoteName('a.alias'),
|
|
$db->quoteName('a.menutype'),
|
|
$db->quoteName('a.parent_id'),
|
|
$db->quoteName('a.link'),
|
|
$db->quoteName('a.type'),
|
|
$db->quoteName('a.published'),
|
|
$db->quoteName('a.access'),
|
|
$db->quoteName('a.language'),
|
|
$db->quoteName('a.params'),
|
|
$db->quoteName('a.home'),
|
|
$db->quoteName('a.component_id'),
|
|
$db->quoteName('b.alias', 'parent_alias'),
|
|
])
|
|
->from($db->quoteName('#__menu', 'a'))
|
|
->leftJoin(
|
|
$db->quoteName('#__menu', 'b') . ' ON '
|
|
. $db->quoteName('a.parent_id') . ' = ' . $db->quoteName('b.id')
|
|
)
|
|
->where($db->quoteName('a.published') . ' != -2')
|
|
->where($db->quoteName('a.client_id') . ' = 0')
|
|
->where($db->quoteName('a.level') . ' >= 1')
|
|
->order($db->quoteName('a.lft') . ' ASC')
|
|
->setLimit(self::MAX_ITEMS);
|
|
|
|
$db->setQuery($query);
|
|
$rows = $db->loadAssocList();
|
|
|
|
// Get component name map
|
|
$compQuery = $db->getQuery(true)
|
|
->select([$db->quoteName('extension_id'), $db->quoteName('element')])
|
|
->from($db->quoteName('#__extensions'))
|
|
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
|
|
$db->setQuery($compQuery);
|
|
$components = $db->loadAssocList('extension_id') ?: [];
|
|
|
|
$items = [];
|
|
|
|
foreach ($rows as $row)
|
|
{
|
|
$link = $row['link'];
|
|
|
|
// Encode category IDs in com_content links as {catid:path} tokens
|
|
if (preg_match('/option=com_content/', $link) && preg_match('/&id=(\d+)/', $link, $m))
|
|
{
|
|
$catId = (int) $m[1];
|
|
|
|
if (isset($this->catPathMap[$catId]))
|
|
{
|
|
$link = preg_replace('/&id=\d+/', '&id={catid:' . $this->catPathMap[$catId] . '}', $link);
|
|
}
|
|
}
|
|
|
|
$compName = '';
|
|
|
|
if (!empty($row['component_id']) && isset($components[$row['component_id']]))
|
|
{
|
|
$compName = $components[$row['component_id']]['element'];
|
|
}
|
|
|
|
// Root-level items have parent_id=1 (Joomla's root menu item)
|
|
$parentAlias = ((int) $row['parent_id'] <= 1) ? '' : ($row['parent_alias'] ?? '');
|
|
|
|
$items[] = [
|
|
'title' => $row['title'],
|
|
'alias' => $row['alias'],
|
|
'menutype' => $row['menutype'],
|
|
'parent_alias' => $parentAlias,
|
|
'link' => $link,
|
|
'type' => $row['type'],
|
|
'component_name' => $compName,
|
|
'published' => (int) $row['published'],
|
|
'access' => (int) $row['access'],
|
|
'language' => $row['language'],
|
|
'params' => json_decode($row['params'] ?: '{}', true),
|
|
'home' => (int) $row['home'],
|
|
];
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* Build module payload with menu assignments.
|
|
*
|
|
* @return array
|
|
*
|
|
* @since 02.21.00
|
|
*/
|
|
private function buildModulePayload(): array
|
|
{
|
|
$db = $this->db;
|
|
$query = $db->getQuery(true)
|
|
->select([
|
|
$db->quoteName('id'),
|
|
$db->quoteName('title'),
|
|
$db->quoteName('module'),
|
|
$db->quoteName('position'),
|
|
$db->quoteName('content'),
|
|
$db->quoteName('published'),
|
|
$db->quoteName('access'),
|
|
$db->quoteName('language'),
|
|
$db->quoteName('params'),
|
|
$db->quoteName('client_id'),
|
|
])
|
|
->from($db->quoteName('#__modules'))
|
|
->where($db->quoteName('client_id') . ' = 0')
|
|
->where($db->quoteName('published') . ' != -2')
|
|
->order($db->quoteName('ordering') . ' ASC')
|
|
->setLimit(self::MAX_ITEMS);
|
|
|
|
$db->setQuery($query);
|
|
$rows = $db->loadAssocList();
|
|
|
|
// Get all module-menu assignments
|
|
$mmQuery = $db->getQuery(true)
|
|
->select([
|
|
$db->quoteName('mm.moduleid'),
|
|
$db->quoteName('mm.menuid'),
|
|
$db->quoteName('m.alias', 'menu_alias'),
|
|
$db->quoteName('m.menutype'),
|
|
])
|
|
->from($db->quoteName('#__modules_menu', 'mm'))
|
|
->leftJoin(
|
|
$db->quoteName('#__menu', 'm') . ' ON '
|
|
. $db->quoteName('mm.menuid') . ' = ' . $db->quoteName('m.id')
|
|
);
|
|
$db->setQuery($mmQuery);
|
|
$allAssignments = $db->loadAssocList();
|
|
|
|
// Group assignments by module ID
|
|
$assignmentsByModule = [];
|
|
|
|
foreach ($allAssignments as $a)
|
|
{
|
|
$assignmentsByModule[(int) $a['moduleid']][] = $a;
|
|
}
|
|
|
|
$modules = [];
|
|
|
|
foreach ($rows as $row)
|
|
{
|
|
$moduleId = (int) $row['id'];
|
|
$assignments = $assignmentsByModule[$moduleId] ?? [];
|
|
|
|
// Determine assignment type: 0 = all pages, positive = selected, negative = excluded
|
|
$menuAliases = [];
|
|
$assignType = 0;
|
|
|
|
if (!empty($assignments))
|
|
{
|
|
$firstMenuId = (int) $assignments[0]['menuid'];
|
|
|
|
if ($firstMenuId === 0)
|
|
{
|
|
$assignType = 0; // All pages
|
|
}
|
|
elseif ($firstMenuId < 0)
|
|
{
|
|
$assignType = -1; // All except selected
|
|
foreach ($assignments as $a)
|
|
{
|
|
if (!empty($a['menu_alias']))
|
|
{
|
|
$menuAliases[] = $a['menutype'] . ':' . $a['menu_alias'];
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
$assignType = 1; // Selected only
|
|
foreach ($assignments as $a)
|
|
{
|
|
if (!empty($a['menu_alias']))
|
|
{
|
|
$menuAliases[] = $a['menutype'] . ':' . $a['menu_alias'];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$modules[] = [
|
|
'title' => $row['title'],
|
|
'module' => $row['module'],
|
|
'position' => $row['position'],
|
|
'content' => $row['content'],
|
|
'published' => (int) $row['published'],
|
|
'access' => (int) $row['access'],
|
|
'language' => $row['language'],
|
|
'params' => json_decode($row['params'] ?: '{}', true),
|
|
'client_id' => (int) $row['client_id'],
|
|
'menu_assignment' => [
|
|
'assignment' => $assignType,
|
|
'menu_item_aliases' => $menuAliases,
|
|
],
|
|
];
|
|
}
|
|
|
|
return $modules;
|
|
}
|
|
}
|