Files
MokoSuiteOpenGraph/source/packages/plg_system_mokoog/src/Extension/MokoOG.php
T
Jonathan Miller fcfa6838e5
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 14s
Universal: Auto Version Bump / Version Bump (push) Successful in 16s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 15s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 54s
fix: address PR #82 review findings
- Only emit og:video:secure_url for HTTPS URLs (review #1)
- Only emit og:video:width/height for direct files, not embeds (review #2)
- Add server-side http/https scheme validation on og_video save (review #3)
- Consolidate duplicate com_mokoshop product blocks into one (review #4)
- Fix stale com_virtuemart reference in SQL comment (review #5)
- Use COM_MOKOOG_* language keys in tag.xml instead of plugin keys (review #6)
2026-06-23 10:46:58 -05:00

752 lines
26 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;
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',
];
}
/**
* 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 = Factory::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) {
$doc->setMetaData('twitter:image', $this->resolveImageUrl($image));
}
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::getDbo();
$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 ($this->params->get('jsonld_breadcrumbs', 1)) {
$breadcrumbs = JsonLdBuilder::buildBreadcrumbs();
if ($breadcrumbs) {
$doc->addCustomTag(JsonLdBuilder::toScriptTag($breadcrumbs));
}
}
}
}
/**
* 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' => '',
'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::getDbo();
$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(Factory::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::getDbo();
$lang = Factory::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::getDbo();
$lang = Factory::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::getDbo();
$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 = [];
if (isset($cache[$id])) {
return $cache[$id];
}
$db = Factory::getDbo();
$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);
return $article->$field ?? '';
}
/**
* 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 ?? '';
}
/**
* Warn administrators once per session when no license key is configured.
*
* @return void
*/
private function warnMissingLicenseKey(): void
{
$session = Factory::getSession();
if ($session->get('mokoog.license_warned', false)) {
return;
}
$user = Factory::getUser();
if ($user->guest || !$user->authorise('core.manage')) {
return;
}
try {
$db = Factory::getDbo();
$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::getDbo();
$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]];
}
}