fix(security): harden controllers, add site defaults, platform-specific OG tags
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 7s
Update Server / Update Server (push) Successful in 11s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 7s
Update Server / Update Server (push) Successful in 11s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Security fixes: - Fix JSON-LD XSS via </script> injection in content data (#34) - Add ACL permission checks to Batch and ImportExport controllers (#37) - Add CSV import file type, MIME, and size validation (#35) - Fix multilingual bug in content plugin load/save OG data (#41) Enhancements: - Add site-wide default OG title and description plugin parameters - Add Discord embed color (theme-color) plugin parameter - Add og:image:width/height for faster social previews - Add article:published_time, article:modified_time, article:author for LinkedIn - Add onMokoOGAfterRender event for third-party plugin extensibility - Add content_type regex validation on CSV import rows Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,5 +53,7 @@ COM_MOKOOG_BATCH_ERROR="Error:"
|
||||
COM_MOKOOG_TOOLBAR_EXPORT="Export CSV"
|
||||
COM_MOKOOG_TOOLBAR_IMPORT="Import CSV"
|
||||
COM_MOKOOG_IMPORT_NO_FILE="No CSV file was uploaded."
|
||||
COM_MOKOOG_IMPORT_INVALID_TYPE="Invalid file type. Please upload a .csv file."
|
||||
COM_MOKOOG_IMPORT_FILE_TOO_LARGE="File is too large. Maximum allowed size is %s."
|
||||
COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file."
|
||||
COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped."
|
||||
|
||||
@@ -53,5 +53,7 @@ COM_MOKOOG_BATCH_ERROR="Error:"
|
||||
COM_MOKOOG_TOOLBAR_EXPORT="Export CSV"
|
||||
COM_MOKOOG_TOOLBAR_IMPORT="Import CSV"
|
||||
COM_MOKOOG_IMPORT_NO_FILE="No CSV file was uploaded."
|
||||
COM_MOKOOG_IMPORT_INVALID_TYPE="Invalid file type. Please upload a .csv file."
|
||||
COM_MOKOOG_IMPORT_FILE_TOO_LARGE="File is too large. Maximum allowed size is %s."
|
||||
COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file."
|
||||
COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped."
|
||||
|
||||
@@ -29,6 +29,10 @@ class BatchController extends BaseController
|
||||
{
|
||||
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) {
|
||||
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
@@ -58,6 +62,10 @@ class BatchController extends BaseController
|
||||
{
|
||||
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) {
|
||||
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||
}
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$offset = $app->getInput()->getInt('offset', 0);
|
||||
$limit = $app->getInput()->getInt('limit', 50);
|
||||
|
||||
@@ -19,6 +19,16 @@ use Joomla\CMS\Session\Session;
|
||||
|
||||
class ImportExportController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Maximum upload file size in bytes (2 MB).
|
||||
*/
|
||||
private const MAX_FILE_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Allowed content_type patterns for import.
|
||||
*/
|
||||
private const CONTENT_TYPE_PATTERN = '/^[a-z][a-z0-9_.]*$/';
|
||||
|
||||
/**
|
||||
* Export all OG tags as CSV.
|
||||
*
|
||||
@@ -28,6 +38,10 @@ class ImportExportController extends BaseController
|
||||
{
|
||||
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokoog')) {
|
||||
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||
}
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$db = Factory::getDbo();
|
||||
|
||||
@@ -88,6 +102,12 @@ class ImportExportController extends BaseController
|
||||
{
|
||||
Session::checkToken() || jexit(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$identity = Factory::getApplication()->getIdentity();
|
||||
|
||||
if (!$identity->authorise('core.create', 'com_mokoog') || !$identity->authorise('core.edit', 'com_mokoog')) {
|
||||
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
|
||||
}
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$input = $app->getInput();
|
||||
$files = $input->files->get('jform', [], 'array');
|
||||
@@ -99,7 +119,37 @@ class ImportExportController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
$tmpFile = $files['csv_file']['tmp_name'];
|
||||
$csvFile = $files['csv_file'];
|
||||
|
||||
// Validate file extension
|
||||
$ext = strtolower(pathinfo($csvFile['name'] ?? '', PATHINFO_EXTENSION));
|
||||
|
||||
if ($ext !== 'csv') {
|
||||
$app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_INVALID_TYPE'), 'error');
|
||||
$app->redirect('index.php?option=com_mokoog&view=tags');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate MIME type
|
||||
$allowedMimes = ['text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel'];
|
||||
|
||||
if (!empty($csvFile['type']) && !\in_array($csvFile['type'], $allowedMimes, true)) {
|
||||
$app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_INVALID_TYPE'), 'error');
|
||||
$app->redirect('index.php?option=com_mokoog&view=tags');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (($csvFile['size'] ?? 0) > self::MAX_FILE_SIZE) {
|
||||
$app->enqueueMessage(Text::sprintf('COM_MOKOOG_IMPORT_FILE_TOO_LARGE', '2 MB'), 'error');
|
||||
$app->redirect('index.php?option=com_mokoog&view=tags');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tmpFile = $csvFile['tmp_name'];
|
||||
$handle = fopen($tmpFile, 'r');
|
||||
|
||||
if (!$handle) {
|
||||
@@ -141,6 +191,13 @@ class ImportExportController extends BaseController
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate content_type against allowed pattern
|
||||
if (!preg_match(self::CONTENT_TYPE_PATTERN, $contentType)) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for existing record
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
|
||||
@@ -94,7 +94,8 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
'com_categories.categorycom_content' => 'com_content.category',
|
||||
];
|
||||
$contentType = $formTypeMap[$formName] ?? 'com_content';
|
||||
$ogData = $this->loadOgData($contentType, $id);
|
||||
$language = $this->getContentLanguage($data);
|
||||
$ogData = $this->loadOgData($contentType, $id, $language);
|
||||
|
||||
if ($ogData) {
|
||||
$form->bind(['mokoog' => (array) $ogData]);
|
||||
@@ -132,6 +133,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
|
||||
$contentType = $supportedContexts[$context];
|
||||
$contentId = (int) $article->id;
|
||||
$language = $this->getContentLanguage($article);
|
||||
|
||||
$input = $app->getInput();
|
||||
$jform = $input->get('jform', [], 'array');
|
||||
@@ -141,7 +143,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$this->saveOgData($contentType, $contentId, $ogData);
|
||||
$this->saveOgData($contentType, $contentId, $ogData, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,14 +181,15 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing OG data for a content item.
|
||||
* Load existing OG data for a content item, filtered by language.
|
||||
*
|
||||
* @param string $contentType Content type identifier
|
||||
* @param int $contentId Content ID
|
||||
* @param string $language Language tag (e.g. 'en-GB') or '*' for all
|
||||
*
|
||||
* @return object|null
|
||||
*/
|
||||
private function loadOgData(string $contentType, int $contentId): ?object
|
||||
private function loadOgData(string $contentType, int $contentId, string $language = '*'): ?object
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
@@ -196,9 +199,12 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
]))
|
||||
->from($db->quoteName('#__mokoog_tags'))
|
||||
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
|
||||
->where($db->quoteName('content_id') . ' = ' . $contentId);
|
||||
->where($db->quoteName('content_id') . ' = ' . $contentId)
|
||||
->where('(' . $db->quoteName('language') . ' = ' . $db->quote($language)
|
||||
. ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')')
|
||||
->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->setQuery($query, 0, 1);
|
||||
|
||||
return $db->loadObject();
|
||||
}
|
||||
@@ -209,19 +215,21 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
* @param string $contentType Content type identifier
|
||||
* @param int $contentId Content ID
|
||||
* @param array $ogData OG field values
|
||||
* @param string $language Language tag (e.g. 'en-GB') or '*' for all
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function saveOgData(string $contentType, int $contentId, array $ogData): void
|
||||
private function saveOgData(string $contentType, int $contentId, array $ogData, string $language = '*'): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Check if record exists
|
||||
// Check if record exists for this content + language
|
||||
$query = $db->getQuery(true)
|
||||
->select('id')
|
||||
->from($db->quoteName('#__mokoog_tags'))
|
||||
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
|
||||
->where($db->quoteName('content_id') . ' = ' . $contentId);
|
||||
->where($db->quoteName('content_id') . ' = ' . $contentId)
|
||||
->where($db->quoteName('language') . ' = ' . $db->quote($language));
|
||||
|
||||
$db->setQuery($query);
|
||||
$existingId = $db->loadResult();
|
||||
@@ -236,6 +244,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
$record = (object) [
|
||||
'content_type' => $contentType,
|
||||
'content_id' => $contentId,
|
||||
'language' => $language,
|
||||
'og_title' => trim($ogData['og_title'] ?? ''),
|
||||
'og_description' => trim($ogData['og_description'] ?? ''),
|
||||
'og_image' => trim($ogData['og_image'] ?? ''),
|
||||
@@ -256,4 +265,24 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
$db->insertObject('#__mokoog_tags', $record);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the language tag from content data.
|
||||
*
|
||||
* @param object|array $data Content data from form or article object
|
||||
*
|
||||
* @return string Language tag (e.g. 'en-GB') or '*' for all languages
|
||||
*/
|
||||
private function getContentLanguage($data): string
|
||||
{
|
||||
$language = '*';
|
||||
|
||||
if (\is_object($data) && isset($data->language)) {
|
||||
$language = $data->language;
|
||||
} elseif (\is_array($data) && isset($data['language'])) {
|
||||
$language = $data['language'];
|
||||
}
|
||||
|
||||
return !empty($language) ? $language : '*';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ PLG_SYSTEM_MOKOOG_FIELDSET_ADVANCED="Advanced Settings"
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME="Site Name"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME_DESC="The og:site_name value. Leave blank to use the Joomla site name."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE="Default OG Title"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE_DESC="Site-wide fallback title for social sharing. Used when a page has no custom OG title. Leave blank to use the page title."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION="Default OG Description"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION_DESC="Site-wide fallback description for social sharing. Used when a page has no custom OG description and no meta description. Leave blank to auto-generate from page content."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_IMAGE="Default Image"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_IMAGE_DESC="Fallback image used when no article or page image is found."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_CARD="Twitter Card Type"
|
||||
@@ -17,6 +21,8 @@ PLG_SYSTEM_MOKOOG_FIELD_TWITTER_SITE="Twitter @username"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_SITE_DESC="Your site's Twitter handle (e.g. @mokoconsulting)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID="Facebook App ID"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID_DESC="Your Facebook App ID for fb:app_id meta tag."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR="Discord Embed Color"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR_DESC="The color of the embed sidebar when shared on Discord. Sets the theme-color meta tag. Leave blank to use Discord defaults."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE="Auto-generate Tags"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE_DESC="Automatically generate OG tags from article content when no custom tags are set."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description"
|
||||
|
||||
@@ -7,6 +7,10 @@ PLG_SYSTEM_MOKOOG_FIELDSET_ADVANCED="Advanced Settings"
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME="Site Name"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME_DESC="The og:site_name value. Leave blank to use the Joomla site name."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE="Default OG Title"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE_DESC="Site-wide fallback title for social sharing. Used when a page has no custom OG title. Leave blank to use the page title."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION="Default OG Description"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION_DESC="Site-wide fallback description for social sharing. Used when a page has no custom OG description and no meta description. Leave blank to auto-generate from page content."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_IMAGE="Default Image"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_IMAGE_DESC="Fallback image used when no article or page image is found."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_CARD="Twitter Card Type"
|
||||
@@ -17,6 +21,8 @@ PLG_SYSTEM_MOKOOG_FIELD_TWITTER_SITE="Twitter @username"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_SITE_DESC="Your site's Twitter handle (e.g. @mokoconsulting)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID="Facebook App ID"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID_DESC="Your Facebook App ID for fb:app_id meta tag."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR="Discord Embed Color"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR_DESC="The color of the embed sidebar when shared on Discord. Sets the theme-color meta tag. Leave blank to use Discord defaults."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE="Auto-generate Tags"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE_DESC="Automatically generate OG tags from article content when no custom tags are set."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description"
|
||||
|
||||
@@ -41,6 +41,23 @@
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME_DESC"
|
||||
default=""
|
||||
/>
|
||||
<field
|
||||
name="default_og_title"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="default_og_description"
|
||||
type="textarea"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
rows="3"
|
||||
/>
|
||||
<field
|
||||
name="default_image"
|
||||
type="media"
|
||||
@@ -82,6 +99,13 @@
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="discord_color"
|
||||
type="color"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR_DESC"
|
||||
default=""
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset name="advanced" label="PLG_SYSTEM_MOKOOG_FIELDSET_ADVANCED">
|
||||
<field
|
||||
|
||||
@@ -86,9 +86,12 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
// --- SEO meta tags (set first, before OG) ---
|
||||
$this->applySeoTags($doc, $ogData);
|
||||
|
||||
// Build tag values — custom overrides auto-generated
|
||||
$title = $ogData->og_title ?: $doc->getTitle();
|
||||
$description = $ogData->og_description ?: $this->buildDescription($doc);
|
||||
// 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', ''));
|
||||
@@ -104,6 +107,10 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
if ($image) {
|
||||
$imageUrl = $this->resolveImageUrl($image);
|
||||
$doc->setMetaData('og:image', $imageUrl, 'property');
|
||||
|
||||
// Image dimensions help Facebook, LinkedIn, and Discord render previews faster
|
||||
$doc->setMetaData('og:image:width', '1200', 'property');
|
||||
$doc->setMetaData('og:image:height', '630', 'property');
|
||||
}
|
||||
|
||||
// og:locale from current language
|
||||
@@ -141,6 +148,39 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
$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);
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
// 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) : '';
|
||||
@@ -404,4 +444,47 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
|
||||
return rtrim(Uri::root(), '/') . '/' . ltrim($image, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName($field))
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
|
||||
$db->setQuery($query);
|
||||
$date = $db->loadResult();
|
||||
|
||||
return $date ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the author name for an article.
|
||||
*
|
||||
* @param int $id Article ID
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getArticleAuthor(int $id): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('u.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);
|
||||
|
||||
return $db->loadResult() ?: '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,9 @@ class JsonLdBuilder
|
||||
{
|
||||
$json = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
|
||||
// Escape </ sequences to prevent XSS via </script> in content data
|
||||
$json = str_replace('</', '<\/', $json);
|
||||
|
||||
return '<script type="application/ld+json">' . $json . '</script>';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user