* @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 */ 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 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 { // Opportunistic maintenance on content save: prune stale generated images // so the generated-image cache cannot grow without bound. ImageHelper::pruneOldFiles(); 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]]; } }