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
- 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)
752 lines
26 KiB
PHP
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]];
|
|
}
|
|
}
|