71a102028d
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 16s
#99 — AI AJAX endpoint hardening: - require core.edit/core.create on com_content before generating (was reachable by any authenticated back-end user → paid-credit abuse) - callAiApi: 20s timeout + HTTP status check (throw on non-200) instead of silently returning an empty string #100 — Sitemap information disclosure + robustness: - filter to public (guest) view levels so registered/special-access articles are never written into the public sitemap - atomic write (temp file + rename) so concurrent saves can't expose a half-written sitemap.xml - (throttling + SEF URLs remain follow-ups, noted on the issue) #101 — Expose newer columns in CSV + API: - og_video, event_data, recipe_data, custom_schema added to CSV export/import (appended, so existing CSVs still import) and to the REST API field whitelist - import validates JSON fields as arrays/objects and og_video as http(s) (prevents re-introducing the #97 scalar-JSON-LD crash via import) #102 — Forward-compat (complete): - Factory::getLanguage() -> getApplication()->getLanguage() (4 sites) - Joomla\CMS\Filesystem\File/Folder -> Joomla\Filesystem\* (ImageHelper, ImageGenerator) #106 — partial: loadArticle() now caches null misses (array_key_exists), getArticleDate() skips 0000-00-00 dates. Batch-JS halt deferred — the offset=0 design re-fetches failed rows, so the created>0 guard prevents an infinite loop; a safe fix needs cursor-based pagination in BatchController.
1101 lines
38 KiB
PHP
1101 lines
38 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoSuiteOpenGraph
|
|
* @subpackage plg_system_mokoog
|
|
* @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
|
|
*/
|
|
|
|
namespace Joomla\Plugin\System\MokoOG\Extension;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\Plugin\CMSPlugin;
|
|
use Joomla\CMS\Uri\Uri;
|
|
use Joomla\Event\Event;
|
|
use Joomla\Event\SubscriberInterface;
|
|
use Joomla\Plugin\System\MokoOG\Helper\ImageHelper;
|
|
use Joomla\Plugin\System\MokoOG\Helper\JsonLdBuilder;
|
|
use Joomla\Plugin\System\MokoOG\Helper\SitemapBuilder;
|
|
|
|
final class MokoOG extends CMSPlugin implements SubscriberInterface
|
|
{
|
|
/**
|
|
* @var bool
|
|
*/
|
|
protected $autoloadLanguage = true;
|
|
|
|
/**
|
|
* Returns the events this plugin subscribes to.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
public static function getSubscribedEvents(): array
|
|
{
|
|
return [
|
|
'onAfterRoute' => 'onAfterRoute',
|
|
'onBeforeCompileHead' => 'onBeforeCompileHead',
|
|
'onContentAfterSave' => 'onContentAfterSaveRebuildSitemap',
|
|
'onAjaxMokoog' => 'onAjaxMokoog',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Run admin-side license key check after routing.
|
|
*
|
|
* @param Event $event The event object
|
|
*
|
|
* @return void
|
|
*/
|
|
public function onAfterRoute(Event $event): void
|
|
{
|
|
$app = $this->getApplication();
|
|
|
|
if ($app->isClient('administrator')) {
|
|
$this->warnMissingLicenseKey();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Inject Open Graph and Twitter Card meta tags before the document head is compiled.
|
|
*
|
|
* @param Event $event The event object
|
|
*
|
|
* @return void
|
|
*/
|
|
public function onBeforeCompileHead(Event $event): void
|
|
{
|
|
$app = $this->getApplication();
|
|
|
|
// Only run on the site frontend
|
|
if (!$app->isClient('site')) {
|
|
return;
|
|
}
|
|
|
|
$doc = $app->getDocument();
|
|
|
|
if ($doc->getType() !== 'html') {
|
|
return;
|
|
}
|
|
|
|
$input = $app->getInput();
|
|
$option = $input->getCmd('option', '');
|
|
$view = $input->getCmd('view', '');
|
|
$id = $input->getInt('id', 0);
|
|
|
|
// Try to load custom OG data from the database
|
|
$ogData = $this->loadOgData($option, $view, $id);
|
|
|
|
// For category views, also try category-level OG data as fallback
|
|
if ($option === 'com_content' && $view === 'category' && $id > 0) {
|
|
$catOg = $this->loadOgDataByType('com_content.category', $id);
|
|
|
|
if ($catOg) {
|
|
// Merge: category fills any gaps in the content-level data
|
|
foreach (['og_title', 'og_description', 'og_image', 'og_type', 'og_video', 'seo_title', 'meta_description', 'robots', 'canonical_url'] as $field) {
|
|
if (empty($ogData->$field) && !empty($catOg->$field)) {
|
|
$ogData->$field = $catOg->$field;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- SEO meta tags (set first, before OG) ---
|
|
$this->applySeoTags($doc, $ogData);
|
|
|
|
// Build tag values — custom OG data → site-wide defaults → auto-generated
|
|
$defaultTitle = $this->params->get('default_og_title', '');
|
|
$defaultDesc = $this->params->get('default_og_description', '');
|
|
|
|
$title = $ogData->og_title ?: ($doc->getTitle() ?: $defaultTitle);
|
|
$description = $ogData->og_description ?: ($this->buildDescription($doc) ?: $defaultDesc);
|
|
$image = $ogData->og_image ?: $this->findImage($option, $view, $id);
|
|
$url = Uri::getInstance()->toString();
|
|
$siteName = $this->params->get('og_site_name', $app->get('sitename', ''));
|
|
$defaultType = ($option === 'com_mokoshop' && $view === 'product') ? 'product' : 'article';
|
|
$type = $ogData->og_type ?: $defaultType;
|
|
|
|
// Open Graph tags
|
|
$doc->setMetaData('og:title', $title, 'property');
|
|
$doc->setMetaData('og:description', $description, 'property');
|
|
$doc->setMetaData('og:url', $url, 'property');
|
|
$doc->setMetaData('og:type', $type, 'property');
|
|
$doc->setMetaData('og:site_name', $siteName, 'property');
|
|
|
|
if ($image) {
|
|
$imageUrl = $this->resolveImageUrl($image);
|
|
$doc->setMetaData('og:image', $imageUrl, 'property');
|
|
|
|
// Emit actual image dimensions when detectable
|
|
$imageDims = $this->getImageDimensions($image);
|
|
|
|
if ($imageDims) {
|
|
$doc->setMetaData('og:image:width', (string) $imageDims[0], 'property');
|
|
$doc->setMetaData('og:image:height', (string) $imageDims[1], 'property');
|
|
}
|
|
}
|
|
|
|
// og:locale from current language
|
|
$langTag = $this->getApplication()->getLanguage()->getTag();
|
|
$ogLocale = str_replace('-', '_', $langTag);
|
|
$doc->setMetaData('og:locale', $ogLocale, 'property');
|
|
|
|
// Facebook App ID
|
|
$fbAppId = $this->params->get('fb_app_id', '');
|
|
|
|
if ($fbAppId) {
|
|
$doc->setMetaData('fb:app_id', $fbAppId, 'property');
|
|
}
|
|
|
|
// Twitter Card tags
|
|
$cardType = $this->params->get('twitter_card_type', 'summary_large_image');
|
|
$twitterSite = $this->params->get('twitter_site', '');
|
|
|
|
$doc->setMetaData('twitter:card', $cardType);
|
|
$doc->setMetaData('twitter:title', $title);
|
|
$doc->setMetaData('twitter:description', $description);
|
|
|
|
if ($image) {
|
|
$twitterImage = ($this->params->get('auto_resize', 1) && $this->params->get('platform_resize', 0))
|
|
? ImageHelper::resizeForPlatform($image, 'twitter')
|
|
: $image;
|
|
$doc->setMetaData('twitter:image', $this->resolveImageUrl($twitterImage));
|
|
}
|
|
|
|
if ($twitterSite) {
|
|
$doc->setMetaData('twitter:site', $twitterSite);
|
|
}
|
|
|
|
// Telegram channel tag
|
|
$telegramChannel = $this->params->get('telegram_channel', '');
|
|
|
|
if ($telegramChannel) {
|
|
$doc->setMetaData('telegram:channel', $telegramChannel);
|
|
}
|
|
|
|
// Discord embed color (theme-color meta tag)
|
|
$discordColor = $this->params->get('discord_color', '');
|
|
|
|
if ($discordColor) {
|
|
$doc->setMetaData('theme-color', $discordColor);
|
|
}
|
|
|
|
// Fediverse/Mastodon creator attribution
|
|
$fediverseCreator = $this->params->get('fediverse_creator', '');
|
|
|
|
if ($fediverseCreator) {
|
|
$doc->setMetaData('fediverse:creator', $fediverseCreator);
|
|
}
|
|
|
|
// og:video tags
|
|
$videoUrl = $ogData->og_video ?? '';
|
|
|
|
if ($videoUrl) {
|
|
$doc->setMetaData('og:video', $videoUrl, 'property');
|
|
|
|
if (str_starts_with($videoUrl, 'https://')) {
|
|
$doc->setMetaData('og:video:secure_url', $videoUrl, 'property');
|
|
}
|
|
|
|
// Detect video type from URL — embeds vs direct files
|
|
$isEmbed = str_contains($videoUrl, 'youtube.com') || str_contains($videoUrl, 'youtu.be')
|
|
|| str_contains($videoUrl, 'vimeo.com');
|
|
|
|
if ($isEmbed) {
|
|
$doc->setMetaData('og:video:type', 'text/html', 'property');
|
|
} else {
|
|
$ext = strtolower(pathinfo(parse_url($videoUrl, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION));
|
|
$mimeMap = ['mp4' => 'video/mp4', 'webm' => 'video/webm', 'ogg' => 'video/ogg'];
|
|
$doc->setMetaData('og:video:type', $mimeMap[$ext] ?? 'video/mp4', 'property');
|
|
$doc->setMetaData('og:video:width', '1280', 'property');
|
|
$doc->setMetaData('og:video:height', '720', 'property');
|
|
}
|
|
}
|
|
|
|
// LinkedIn article tags
|
|
if ($option === 'com_content' && $view === 'article' && $id > 0) {
|
|
$doc->setMetaData('article:published_time', $this->getArticleDate($id, 'publish_up'), 'property');
|
|
$doc->setMetaData('article:modified_time', $this->getArticleDate($id, 'modified'), 'property');
|
|
|
|
$author = $this->getArticleAuthor($id);
|
|
|
|
if ($author) {
|
|
$doc->setMetaData('article:author', $author, 'property');
|
|
}
|
|
}
|
|
|
|
// MokoSuiteShop product meta tags (pricing + Pinterest availability)
|
|
if ($option === 'com_mokoshop' && $view === 'product' && $id > 0) {
|
|
$productData = $this->loadShopProduct($id);
|
|
|
|
if ($productData) {
|
|
$doc->setMetaData('product:price:amount', number_format((float) $productData->price, 2, '.', ''), 'property');
|
|
$doc->setMetaData('product:price:currency', $productData->currency ?: 'USD', 'property');
|
|
$availability = ((int) ($productData->stock_qty ?? 0) > 0) ? 'instock' : 'outofstock';
|
|
$doc->setMetaData('product:availability', $availability, 'property');
|
|
}
|
|
}
|
|
|
|
// Pinterest article:tag rich pins (from Joomla content tags)
|
|
if ($option === 'com_content' && $view === 'article' && $id > 0) {
|
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
$tagQuery = $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') . ' = ' . $id)
|
|
->where($db->quoteName('t.published') . ' = 1');
|
|
$db->setQuery($tagQuery);
|
|
$tags = $db->loadColumn();
|
|
|
|
foreach ($tags as $tag) {
|
|
$doc->setMetaData('article:tag', $tag, 'property');
|
|
}
|
|
}
|
|
|
|
// Fire event so third-party plugins can add custom OG/social tags
|
|
$eventData = [
|
|
'subject' => $doc,
|
|
'title' => $title,
|
|
'description' => $description,
|
|
'image' => $image ? $this->resolveImageUrl($image) : '',
|
|
'url' => $url,
|
|
'type' => $type,
|
|
'option' => $option,
|
|
'view' => $view,
|
|
'id' => $id,
|
|
];
|
|
$app->getDispatcher()->dispatch('onMokoOGAfterRender', new Event('onMokoOGAfterRender', $eventData));
|
|
|
|
// JSON-LD structured data
|
|
if ($this->params->get('jsonld_enabled', 1)) {
|
|
$imageUrl = $image ? $this->resolveImageUrl($image) : '';
|
|
|
|
if ($option === 'com_mokoshop' && $view === 'product' && $id > 0) {
|
|
$schema = JsonLdBuilder::buildProduct($id, $title, $description, $imageUrl, $this->loadShopProduct($id));
|
|
} elseif ($option === 'com_content' && $view === 'article' && $id > 0) {
|
|
$schema = JsonLdBuilder::buildArticle($id, $title, $description, $imageUrl, $this->loadArticle($id));
|
|
} else {
|
|
$schema = JsonLdBuilder::buildWebPage($title, $description);
|
|
}
|
|
|
|
if ($schema) {
|
|
$doc->addCustomTag(JsonLdBuilder::toScriptTag($schema));
|
|
}
|
|
|
|
if (!empty($ogData->og_video)) {
|
|
$videoSchema = JsonLdBuilder::buildVideo($ogData->og_video, $title, $description, $imageUrl);
|
|
|
|
if ($videoSchema) {
|
|
$doc->addCustomTag(JsonLdBuilder::toScriptTag($videoSchema));
|
|
}
|
|
}
|
|
|
|
// FAQ schema (auto-detected from article headings)
|
|
if ($this->params->get('jsonld_faq', 1) && $option === 'com_content' && $view === 'article' && $id > 0) {
|
|
$faqItems = $this->extractFaqFromContent($id);
|
|
|
|
if (!empty($faqItems)) {
|
|
$faqSchema = JsonLdBuilder::buildFaq($faqItems);
|
|
|
|
if ($faqSchema) {
|
|
$doc->addCustomTag(JsonLdBuilder::toScriptTag($faqSchema));
|
|
}
|
|
}
|
|
}
|
|
|
|
// HowTo schema (auto-detected from ordered lists)
|
|
if ($this->params->get('jsonld_howto', 1) && $option === 'com_content' && $view === 'article' && $id > 0) {
|
|
$howToSteps = $this->extractHowToFromContent($id);
|
|
|
|
if (!empty($howToSteps)) {
|
|
$howToSchema = JsonLdBuilder::buildHowTo($title, $howToSteps, $imageUrl);
|
|
|
|
if ($howToSchema) {
|
|
$doc->addCustomTag(JsonLdBuilder::toScriptTag($howToSchema));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Event JSON-LD from per-article event data
|
|
$eventJson = $ogData->event_data ?? '';
|
|
|
|
if (!empty($eventJson)) {
|
|
$eventObj = json_decode($eventJson);
|
|
|
|
if ($eventObj && !empty($eventObj->event_start)) {
|
|
$eventSchema = JsonLdBuilder::buildEvent($title, $description, $imageUrl, $eventObj);
|
|
|
|
if ($eventSchema) {
|
|
$doc->addCustomTag(JsonLdBuilder::toScriptTag($eventSchema));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Recipe JSON-LD from per-article recipe data
|
|
$recipeJson = $ogData->recipe_data ?? '';
|
|
|
|
if (!empty($recipeJson)) {
|
|
$recipeObj = json_decode($recipeJson);
|
|
|
|
if ($recipeObj) {
|
|
$recipeSchema = JsonLdBuilder::buildRecipe($title, $description, $imageUrl, $recipeObj);
|
|
|
|
if ($recipeSchema) {
|
|
$doc->addCustomTag(JsonLdBuilder::toScriptTag($recipeSchema));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Custom JSON-LD schema (user-provided)
|
|
$customSchema = $ogData->custom_schema ?? '';
|
|
|
|
if (!empty($customSchema)) {
|
|
$decoded = json_decode($customSchema, true);
|
|
|
|
// Guard against scalar/invalid payloads — only arrays/objects are
|
|
// valid JSON-LD. Writing an array offset onto a scalar is fatal.
|
|
if (\is_array($decoded) && $decoded !== []) {
|
|
if (empty($decoded['@context'])) {
|
|
$decoded['@context'] = 'https://schema.org';
|
|
}
|
|
|
|
$doc->addCustomTag(JsonLdBuilder::toScriptTag($decoded));
|
|
}
|
|
}
|
|
|
|
if ($this->params->get('jsonld_breadcrumbs', 1)) {
|
|
$breadcrumbs = JsonLdBuilder::buildBreadcrumbs();
|
|
|
|
if ($breadcrumbs) {
|
|
$doc->addCustomTag(JsonLdBuilder::toScriptTag($breadcrumbs));
|
|
}
|
|
}
|
|
}
|
|
|
|
// LocalBusiness JSON-LD
|
|
if ($this->params->get('lb_enabled', 0)) {
|
|
$lbSchema = JsonLdBuilder::buildLocalBusiness($this->params);
|
|
|
|
if ($lbSchema) {
|
|
$doc->addCustomTag(JsonLdBuilder::toScriptTag($lbSchema));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply SEO meta tags (title, description, robots, canonical) to the document.
|
|
*
|
|
* @param \Joomla\CMS\Document\HtmlDocument $doc The document
|
|
* @param object $ogData The loaded OG/SEO data
|
|
*
|
|
* @return void
|
|
*/
|
|
private function applySeoTags($doc, object $ogData): void
|
|
{
|
|
// Custom SEO title overrides the page <title>
|
|
if (!empty($ogData->seo_title)) {
|
|
$doc->setTitle($ogData->seo_title);
|
|
}
|
|
|
|
// Custom meta description
|
|
if (!empty($ogData->meta_description)) {
|
|
$doc->setDescription($ogData->meta_description);
|
|
}
|
|
|
|
// Robots directive
|
|
if (!empty($ogData->robots)) {
|
|
$doc->setMetaData('robots', $ogData->robots);
|
|
}
|
|
|
|
// Canonical URL
|
|
if (!empty($ogData->canonical_url)) {
|
|
// Remove any existing canonical link via public API
|
|
$headData = $doc->getHeadData();
|
|
|
|
if (!empty($headData['links'])) {
|
|
foreach ($headData['links'] as $link => $attribs) {
|
|
if (isset($attribs['relation']) && $attribs['relation'] === 'canonical') {
|
|
unset($headData['links'][$link]);
|
|
}
|
|
}
|
|
|
|
$doc->setHeadData($headData);
|
|
}
|
|
|
|
$doc->addHeadLink($ogData->canonical_url, 'canonical');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load custom OG data from the database for the current page.
|
|
*
|
|
* @param string $option Component option
|
|
* @param string $view View name
|
|
* @param int $id Content ID
|
|
*
|
|
* @return object
|
|
*/
|
|
private function loadOgData(string $option, string $view, int $id): object
|
|
{
|
|
$empty = (object) [
|
|
'og_title' => '',
|
|
'og_description' => '',
|
|
'og_image' => '',
|
|
'og_type' => '',
|
|
'og_video' => '',
|
|
'event_data' => '',
|
|
'recipe_data' => '',
|
|
'custom_schema' => '',
|
|
'seo_title' => '',
|
|
'meta_description' => '',
|
|
'robots' => '',
|
|
'canonical_url' => '',
|
|
];
|
|
|
|
if (!$id) {
|
|
// Try menu-item-based lookup
|
|
$menuItem = $this->getApplication()->getMenu()->getActive();
|
|
|
|
if (!$menuItem) {
|
|
return $empty;
|
|
}
|
|
|
|
return $this->loadOgDataByMenu((int) $menuItem->id) ?: $empty;
|
|
}
|
|
|
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokoog_tags'))
|
|
->where($db->quoteName('content_type') . ' = ' . $db->quote($option))
|
|
->where($db->quoteName('content_id') . ' = ' . (int) $id)
|
|
->where($db->quoteName('published') . ' = 1')
|
|
->where('(' . $db->quoteName('language') . ' = ' . $db->quote($this->getApplication()->getLanguage()->getTag())
|
|
. ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')')
|
|
->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC');
|
|
|
|
$db->setQuery($query, 0, 1);
|
|
|
|
return $db->loadObject() ?: $empty;
|
|
}
|
|
|
|
/**
|
|
* Load OG data by content type and ID.
|
|
*
|
|
* @param string $contentType Content type identifier
|
|
* @param int $contentId Content ID
|
|
*
|
|
* @return object|null
|
|
*/
|
|
private function loadOgDataByType(string $contentType, int $contentId): ?object
|
|
{
|
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
$lang = $this->getApplication()->getLanguage()->getTag();
|
|
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokoog_tags'))
|
|
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
|
|
->where($db->quoteName('content_id') . ' = ' . $contentId)
|
|
->where($db->quoteName('published') . ' = 1')
|
|
->where('(' . $db->quoteName('language') . ' = ' . $db->quote($lang)
|
|
. ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')')
|
|
->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC');
|
|
|
|
$db->setQuery($query, 0, 1);
|
|
|
|
return $db->loadObject();
|
|
}
|
|
|
|
/**
|
|
* Load OG data by menu item ID.
|
|
*
|
|
* @param int $menuId Menu item ID
|
|
*
|
|
* @return object|null
|
|
*/
|
|
private function loadOgDataByMenu(int $menuId): ?object
|
|
{
|
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
$lang = $this->getApplication()->getLanguage()->getTag();
|
|
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__mokoog_tags'))
|
|
->where($db->quoteName('content_type') . ' = ' . $db->quote('menu'))
|
|
->where($db->quoteName('content_id') . ' = ' . $menuId)
|
|
->where($db->quoteName('published') . ' = 1')
|
|
->where('(' . $db->quoteName('language') . ' = ' . $db->quote($lang)
|
|
. ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')')
|
|
->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC');
|
|
|
|
$db->setQuery($query, 0, 1);
|
|
|
|
return $db->loadObject();
|
|
}
|
|
|
|
/**
|
|
* Build a description from the document metadata or page content.
|
|
*
|
|
* @param \Joomla\CMS\Document\HtmlDocument $doc The document
|
|
*
|
|
* @return string
|
|
*/
|
|
private function buildDescription($doc): string
|
|
{
|
|
$description = $doc->getDescription();
|
|
$maxLength = (int) $this->params->get('desc_length', 160);
|
|
|
|
if ($this->params->get('strip_html', 1)) {
|
|
$description = strip_tags($description);
|
|
}
|
|
|
|
$description = trim(preg_replace('/\s+/', ' ', $description));
|
|
|
|
if (mb_strlen($description) > $maxLength) {
|
|
$description = mb_substr($description, 0, $maxLength - 3) . '...';
|
|
}
|
|
|
|
return $description;
|
|
}
|
|
|
|
/**
|
|
* Attempt to find the first image for the given content.
|
|
*
|
|
* @param string $option Component option
|
|
* @param string $view View name
|
|
* @param int $id Content ID
|
|
*
|
|
* @return string
|
|
*/
|
|
private function findImage(string $option, string $view, int $id): string
|
|
{
|
|
if (!$this->params->get('auto_generate', 1)) {
|
|
return $this->params->get('default_image', '');
|
|
}
|
|
|
|
// For MokoSuiteShop products, look at the linked article's images
|
|
if ($option === 'com_mokoshop' && $id > 0) {
|
|
$productData = $this->loadShopProduct($id);
|
|
|
|
if ($productData && !empty($productData->images)) {
|
|
$imagesData = json_decode($productData->images, true);
|
|
|
|
if (!empty($imagesData['image_fulltext'])) {
|
|
return $imagesData['image_fulltext'];
|
|
}
|
|
|
|
if (!empty($imagesData['image_intro'])) {
|
|
return $imagesData['image_intro'];
|
|
}
|
|
}
|
|
|
|
return $this->params->get('default_image', '');
|
|
}
|
|
|
|
// For Joomla articles, look at the intro/full image fields
|
|
if ($option === 'com_content' && $id > 0) {
|
|
$article = $this->loadArticle($id);
|
|
|
|
if ($article && !empty($article->images)) {
|
|
$imagesData = json_decode($article->images, true);
|
|
|
|
if (!empty($imagesData['image_fulltext'])) {
|
|
return $imagesData['image_fulltext'];
|
|
}
|
|
|
|
if (!empty($imagesData['image_intro'])) {
|
|
return $imagesData['image_intro'];
|
|
}
|
|
}
|
|
|
|
// Fallback: check the article's category for an image
|
|
if ($view === 'article') {
|
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
$catQuery = $db->getQuery(true)
|
|
->select($db->quoteName('cat.params'))
|
|
->from($db->quoteName('#__categories', 'cat'))
|
|
->join('INNER', $db->quoteName('#__content', 'a') . ' ON ' . $db->quoteName('a.catid') . ' = ' . $db->quoteName('cat.id'))
|
|
->where($db->quoteName('a.id') . ' = ' . (int) $id);
|
|
|
|
$db->setQuery($catQuery);
|
|
$catParams = $db->loadResult();
|
|
|
|
if ($catParams) {
|
|
$catData = json_decode($catParams, true);
|
|
|
|
if (!empty($catData['image'])) {
|
|
return $catData['image'];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $this->params->get('default_image', '');
|
|
}
|
|
|
|
/**
|
|
* Resolve a relative image path to a full URL, resizing for OG if needed.
|
|
*
|
|
* @param string $image Image path
|
|
*
|
|
* @return string
|
|
*/
|
|
private function resolveImageUrl(string $image): string
|
|
{
|
|
if (str_starts_with($image, 'http://') || str_starts_with($image, 'https://')) {
|
|
return $image;
|
|
}
|
|
|
|
// Auto-resize to OG recommended dimensions if enabled
|
|
if ($this->params->get('auto_resize', 1)) {
|
|
$image = ImageHelper::resize($image);
|
|
}
|
|
|
|
return rtrim(Uri::root(), '/') . '/' . ltrim($image, '/');
|
|
}
|
|
|
|
/**
|
|
* Load and cache a full article record with author for the current request.
|
|
*
|
|
* @param int $id Article ID
|
|
*
|
|
* @return object|null
|
|
*/
|
|
private function loadArticle(int $id): ?object
|
|
{
|
|
static $cache = [];
|
|
|
|
// array_key_exists (not isset) so a negative lookup (null) is also cached
|
|
// and not re-queried on every call within the request.
|
|
if (\array_key_exists($id, $cache)) {
|
|
return $cache[$id];
|
|
}
|
|
|
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName([
|
|
'a.title', 'a.introtext', 'a.fulltext', 'a.images',
|
|
'a.created', 'a.modified', 'a.publish_up', 'a.metadesc',
|
|
]))
|
|
->select($db->quoteName('u.name', 'author_name'))
|
|
->from($db->quoteName('#__content', 'a'))
|
|
->join('LEFT', $db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.created_by'))
|
|
->where($db->quoteName('a.id') . ' = ' . $id);
|
|
|
|
$db->setQuery($query);
|
|
$cache[$id] = $db->loadObject();
|
|
|
|
return $cache[$id];
|
|
}
|
|
|
|
/**
|
|
* Get a date field from an article.
|
|
*
|
|
* @param int $id Article ID
|
|
* @param string $field Date field name (publish_up, modified, created)
|
|
*
|
|
* @return string ISO 8601 date string
|
|
*/
|
|
private function getArticleDate(int $id, string $field): string
|
|
{
|
|
$article = $this->loadArticle($id);
|
|
$value = $article->$field ?? '';
|
|
|
|
// Skip zero/empty dates — emitting "0000-00-00 00:00:00" as
|
|
// article:published_time/modified_time produces invalid metadata.
|
|
if ($value === '' || str_starts_with($value, '0000-00-00')) {
|
|
return '';
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
/**
|
|
* Get the author name for an article.
|
|
*
|
|
* @param int $id Article ID
|
|
*
|
|
* @return string
|
|
*/
|
|
private function getArticleAuthor(int $id): string
|
|
{
|
|
$article = $this->loadArticle($id);
|
|
|
|
return $article->author_name ?? '';
|
|
}
|
|
|
|
/**
|
|
* Extract FAQ question/answer pairs from article content.
|
|
*
|
|
* @param int $articleId Article ID
|
|
*
|
|
* @return array Array of ['question' => '...', 'answer' => '...'] pairs
|
|
*/
|
|
private function extractFaqFromContent(int $articleId): array
|
|
{
|
|
$article = $this->loadArticle($articleId);
|
|
|
|
if (!$article) {
|
|
return [];
|
|
}
|
|
|
|
$content = ($article->introtext ?? '') . ($article->fulltext ?? '');
|
|
|
|
if (trim($content) === '') {
|
|
return [];
|
|
}
|
|
|
|
$faqItems = [];
|
|
|
|
if (preg_match_all('/<h[34][^>]*>(.*?)<\/h[34]>\s*((?:<p[^>]*>.*?<\/p>\s*)+)/si', $content, $matches, PREG_SET_ORDER)) {
|
|
foreach ($matches as $match) {
|
|
$question = trim(strip_tags($match[1]));
|
|
$answer = trim(strip_tags($match[2]));
|
|
|
|
if ($question !== '' && $answer !== '') {
|
|
$faqItems[] = [
|
|
'question' => $question,
|
|
'answer' => $answer,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return $faqItems;
|
|
}
|
|
|
|
/**
|
|
* Extract HowTo steps from ordered lists in article content.
|
|
*
|
|
* @param int $articleId Article ID
|
|
*
|
|
* @return array Array of ['name' => '...', 'text' => '...'] pairs
|
|
*/
|
|
private function extractHowToFromContent(int $articleId): array
|
|
{
|
|
$article = $this->loadArticle($articleId);
|
|
|
|
if (!$article) {
|
|
return [];
|
|
}
|
|
|
|
$content = ($article->introtext ?? '') . ($article->fulltext ?? '');
|
|
|
|
if (!preg_match('/<ol[^>]*>(.*?)<\/ol>/si', $content, $olMatch)) {
|
|
return [];
|
|
}
|
|
|
|
if (!preg_match_all('/<li[^>]*>(.*?)<\/li>/si', $olMatch[1], $liMatches)) {
|
|
return [];
|
|
}
|
|
|
|
$steps = [];
|
|
|
|
foreach ($liMatches[1] as $liHtml) {
|
|
$text = trim(strip_tags($liHtml));
|
|
|
|
if ($text === '') {
|
|
continue;
|
|
}
|
|
|
|
$name = $text;
|
|
|
|
if (preg_match('/<(?:b|strong)[^>]*>(.*?)<\/(?:b|strong)>/si', $liHtml, $boldMatch)) {
|
|
$name = trim(strip_tags($boldMatch[1]));
|
|
} elseif (preg_match('/^([^.!?]+[.!?])/', $text, $sentenceMatch)) {
|
|
$name = trim($sentenceMatch[1]);
|
|
}
|
|
|
|
$steps[] = [
|
|
'name' => $name,
|
|
'text' => $text,
|
|
];
|
|
}
|
|
|
|
return $steps;
|
|
}
|
|
|
|
/**
|
|
* Rebuild sitemap.xml when article content is saved.
|
|
*
|
|
* @param Event $event The event
|
|
*
|
|
* @return void
|
|
*/
|
|
public function onContentAfterSaveRebuildSitemap(Event $event): void
|
|
{
|
|
if (!$this->params->get('sitemap_enabled', 0)) {
|
|
return;
|
|
}
|
|
|
|
[$context] = array_values($event->getArguments());
|
|
|
|
if ($context !== 'com_content.article') {
|
|
return;
|
|
}
|
|
|
|
$changefreq = $this->params->get('sitemap_changefreq', 'weekly');
|
|
$xml = SitemapBuilder::generate($changefreq);
|
|
|
|
if (!SitemapBuilder::writeToFile($xml)) {
|
|
\Joomla\CMS\Log\Log::add('MokoOG: Failed to write sitemap.xml — check file permissions', \Joomla\CMS\Log\Log::WARNING, 'mokoog');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle AJAX requests for AI meta tag generation.
|
|
*
|
|
* @param Event $event The event
|
|
*
|
|
* @return void
|
|
*/
|
|
public function onAjaxMokoog(Event $event): void
|
|
{
|
|
$app = $this->getApplication();
|
|
|
|
if (!$app->isClient('administrator')) {
|
|
return;
|
|
}
|
|
|
|
if (!\Joomla\CMS\Session\Session::checkToken()) {
|
|
$event->setArgument('result', ['Invalid Token']);
|
|
return;
|
|
}
|
|
|
|
// Require article-edit capability — this triggers outbound paid AI calls,
|
|
// so it must not be reachable by every authenticated back-end user.
|
|
if (!$app->getIdentity()->authorise('core.edit', 'com_content')
|
|
&& !$app->getIdentity()->authorise('core.create', 'com_content')) {
|
|
$event->setArgument('result', ['Forbidden — insufficient permissions']);
|
|
return;
|
|
}
|
|
|
|
if (!$this->params->get('ai_enabled', 0)) {
|
|
$event->setArgument('result', ['AI generation is not enabled']);
|
|
return;
|
|
}
|
|
|
|
$apiKey = $this->params->get('ai_api_key', '');
|
|
$provider = $this->params->get('ai_provider', 'claude');
|
|
$model = $this->params->get('ai_model', 'claude-haiku-4-5-20251001');
|
|
|
|
if (empty($apiKey)) {
|
|
$event->setArgument('result', ['API key not configured']);
|
|
return;
|
|
}
|
|
|
|
$input = $app->getInput();
|
|
$field = $input->getString('field', 'title');
|
|
$articleTitle = mb_substr(strip_tags($input->getString('article_title', '')), 0, 200);
|
|
|
|
$prompt = $field === 'title'
|
|
? "Generate a concise, engaging social media sharing title (max 60 characters) for an article titled: \"$articleTitle\". Return only the title text, no quotes or explanation."
|
|
: "Generate a compelling social media sharing description (max 155 characters) for an article titled: \"$articleTitle\". Return only the description text, no quotes or explanation.";
|
|
|
|
try {
|
|
$result = $this->callAiApi($provider, $apiKey, $model, $prompt);
|
|
$event->setArgument('result', [$result]);
|
|
} catch (\Exception $e) {
|
|
$event->setArgument('result', ['Error: ' . $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call an AI API (Claude or OpenAI) with a prompt.
|
|
*
|
|
* @param string $provider Provider name (claude or openai)
|
|
* @param string $apiKey API key
|
|
* @param string $model Model name
|
|
* @param string $prompt Prompt text
|
|
*
|
|
* @return string Generated text
|
|
*/
|
|
private function callAiApi(string $provider, string $apiKey, string $model, string $prompt): string
|
|
{
|
|
$http = \Joomla\CMS\Http\HttpFactory::getHttp();
|
|
|
|
// Cap how long a hung provider can block the admin request.
|
|
$timeout = 20;
|
|
|
|
if ($provider === 'claude') {
|
|
$response = $http->post(
|
|
'https://api.anthropic.com/v1/messages',
|
|
json_encode([
|
|
'model' => $model,
|
|
'max_tokens' => 200,
|
|
'messages' => [['role' => 'user', 'content' => $prompt]],
|
|
]),
|
|
[
|
|
'Content-Type' => 'application/json',
|
|
'x-api-key' => $apiKey,
|
|
'anthropic-version' => '2023-06-01',
|
|
],
|
|
$timeout
|
|
);
|
|
|
|
if ((int) $response->code !== 200) {
|
|
throw new \RuntimeException('Claude API request failed (HTTP ' . (int) $response->code . ')');
|
|
}
|
|
|
|
$data = json_decode($response->body, true);
|
|
|
|
return trim($data['content'][0]['text'] ?? '');
|
|
}
|
|
|
|
$response = $http->post(
|
|
'https://api.openai.com/v1/chat/completions',
|
|
json_encode([
|
|
'model' => $model,
|
|
'max_tokens' => 200,
|
|
'messages' => [['role' => 'user', 'content' => $prompt]],
|
|
]),
|
|
[
|
|
'Content-Type' => 'application/json',
|
|
'Authorization' => 'Bearer ' . $apiKey,
|
|
],
|
|
$timeout
|
|
);
|
|
|
|
if ((int) $response->code !== 200) {
|
|
throw new \RuntimeException('OpenAI API request failed (HTTP ' . (int) $response->code . ')');
|
|
}
|
|
|
|
$data = json_decode($response->body, true);
|
|
|
|
return trim($data['choices'][0]['message']['content'] ?? '');
|
|
}
|
|
|
|
/**
|
|
* Warn administrators once per session when no license key is configured.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function warnMissingLicenseKey(): void
|
|
{
|
|
$session = Factory::getApplication()->getSession();
|
|
|
|
if ($session->get('mokoog.license_warned', false)) {
|
|
return;
|
|
}
|
|
|
|
$user = Factory::getApplication()->getIdentity();
|
|
|
|
if ($user->guest || !$user->authorise('core.manage')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName('extra_query'))
|
|
->from($db->quoteName('#__update_sites'))
|
|
->where($db->quoteName('name') . ' = ' . $db->quote('MokoSuiteOpenGraph Updates'))
|
|
->setLimit(1);
|
|
$db->setQuery($query);
|
|
$extraQuery = (string) $db->loadResult();
|
|
|
|
// Mark as checked only after the DB query succeeds
|
|
$session->set('mokoog.license_warned', true);
|
|
|
|
if (!empty($extraQuery)) {
|
|
parse_str($extraQuery, $parsed);
|
|
|
|
if (!empty($parsed['dlid']) && preg_match('/^MOKO-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/', $parsed['dlid'])) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
$this->getApplication()->enqueueMessage(
|
|
'<strong>Moko Consulting License Key Required</strong> — '
|
|
. 'No download key is configured. Updates will not be available until a valid license key is entered. '
|
|
. 'Go to <a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a> '
|
|
. 'and enter your license key (<code>MOKO-XXXX-XXXX-XXXX-XXXX</code>) in the Download Key field '
|
|
. 'for the MokoSuiteOpenGraph update site.',
|
|
'warning'
|
|
);
|
|
} catch (\Throwable $e) {
|
|
// Don't break admin over a license check, but log for debugging
|
|
\Joomla\CMS\Log\Log::add('MokoOG license check: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load MokoSuiteShop product data by product ID.
|
|
*
|
|
* @param int $productId CRM product ID
|
|
*
|
|
* @return object|null Product with name, description, images, price, currency, sku
|
|
*/
|
|
private function loadShopProduct(int $productId): ?object
|
|
{
|
|
static $cache = [];
|
|
|
|
if (isset($cache[$productId])) {
|
|
return $cache[$productId];
|
|
}
|
|
|
|
try {
|
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
$query = $db->getQuery(true)
|
|
->select('p.id, p.sku, p.price, p.currency, p.stock_qty')
|
|
->select('c.title AS name, c.introtext AS description, c.images')
|
|
->from($db->quoteName('#__mokosuite_crm_products', 'p'))
|
|
->join('LEFT', $db->quoteName('#__content', 'c') . ' ON c.id = p.article_id')
|
|
->where($db->quoteName('p.id') . ' = ' . $productId)
|
|
->where($db->quoteName('p.published') . ' = 1');
|
|
|
|
$db->setQuery($query);
|
|
$cache[$productId] = $db->loadObject();
|
|
} catch (\RuntimeException $e) {
|
|
// MokoSuiteShop tables may not exist
|
|
$cache[$productId] = null;
|
|
}
|
|
|
|
return $cache[$productId];
|
|
}
|
|
|
|
/**
|
|
* Get the actual pixel dimensions of a local image.
|
|
*
|
|
* @param string $image Image path (relative or absolute URL)
|
|
*
|
|
* @return array{0: int, 1: int}|null
|
|
*/
|
|
private function getImageDimensions(string $image): ?array
|
|
{
|
|
// Cannot determine dimensions for external URLs
|
|
if (str_starts_with($image, 'http://') || str_starts_with($image, 'https://')) {
|
|
return null;
|
|
}
|
|
|
|
// If auto-resize is on, the resized image lives in the generated dir
|
|
if ($this->params->get('auto_resize', 1)) {
|
|
$resolved = ImageHelper::resize($image);
|
|
} else {
|
|
$resolved = $image;
|
|
}
|
|
|
|
$absPath = JPATH_ROOT . '/' . ltrim($resolved, '/');
|
|
|
|
if (!is_file($absPath)) {
|
|
return null;
|
|
}
|
|
|
|
$info = getimagesize($absPath);
|
|
|
|
if (!$info) {
|
|
return null;
|
|
}
|
|
|
|
return [$info[0], $info[1]];
|
|
}
|
|
}
|