Files
MokoSuiteOpenGraph/source/packages/plg_system_mokoog/src/Extension/MokoOG.php
T
jmiller 71a102028d
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 16s
fix: security & correctness batch (#99, #100, #101, #102, #106)
#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.
2026-06-29 09:52:51 -05:00

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]];
}
}