Files
MokoSuiteOpenGraph/source/packages/plg_system_mokoog/src/Helper/SitemapBuilder.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

131 lines
4.3 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\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Uri\Uri;
/**
* XML Sitemap builder.
*
* Generates a sitemap.xml containing all published articles, excluding
* those marked with noindex robots directives in the mokoog_tags table.
*/
class SitemapBuilder
{
/**
* Generate sitemap XML content.
*
* @param string $changefreq Default change frequency for entries
*
* @return string Complete sitemap XML
*/
public static function generate(string $changefreq = 'weekly'): string
{
$allowed = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'];
$changefreq = \in_array($changefreq, $allowed, true) ? $changefreq : 'weekly';
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
// Only include content the public (guest, user id 0) can view — never
// leak registered/special-access articles into the public sitemap.
$publicLevels = array_map('intval', \Joomla\CMS\Access\Access::getAuthorisedViewLevels(0));
// Get all published articles
$query = $db->getQuery(true)
->select($db->quoteName(['a.id', 'a.alias', 'a.catid', 'a.modified', 'a.language']))
->from($db->quoteName('#__content', 'a'))
->where($db->quoteName('a.state') . ' = 1');
if (!empty($publicLevels)) {
$query->where($db->quoteName('a.access') . ' IN (' . implode(',', $publicLevels) . ')');
}
$db->setQuery($query);
$articles = $db->loadObjectList();
// Get noindex articles from mokoog_tags
$noindexQuery = $db->getQuery(true)
->select($db->quoteName('content_id'))
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content'))
->where($db->quoteName('robots') . ' LIKE ' . $db->quote('%noindex%'));
$db->setQuery($noindexQuery);
$noindexIds = array_map('intval', $db->loadColumn());
$root = rtrim(Uri::root(), '/');
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
// Homepage
$xml .= ' <url>' . "\n";
$xml .= ' <loc>' . $root . '/</loc>' . "\n";
$xml .= ' <changefreq>daily</changefreq>' . "\n";
$xml .= ' <priority>1.0</priority>' . "\n";
$xml .= ' </url>' . "\n";
foreach ($articles as $article) {
// Skip noindexed
if (\in_array((int) $article->id, $noindexIds, true)) {
continue;
}
$url = $root . '/index.php?option=com_content&view=article&id=' . $article->id;
$lastmod = $article->modified && $article->modified !== '0000-00-00 00:00:00'
? date('Y-m-d', strtotime($article->modified)) : '';
$xml .= ' <url>' . "\n";
$xml .= ' <loc>' . htmlspecialchars($url, ENT_XML1) . '</loc>' . "\n";
if ($lastmod) {
$xml .= ' <lastmod>' . $lastmod . '</lastmod>' . "\n";
}
$xml .= ' <changefreq>' . $changefreq . '</changefreq>' . "\n";
$xml .= ' <priority>0.8</priority>' . "\n";
$xml .= ' </url>' . "\n";
}
$xml .= '</urlset>';
return $xml;
}
/**
* Write sitemap XML to the site root.
*
* @param string $xml The sitemap XML content
*
* @return bool True on success
*/
public static function writeToFile(string $xml): bool
{
$path = JPATH_ROOT . '/sitemap.xml';
$tmp = $path . '.' . uniqid('tmp', true);
if (file_put_contents($tmp, $xml) === false) {
return false;
}
// Atomic replace so concurrent saves never expose a half-written sitemap.
if (!@rename($tmp, $path)) {
@unlink($tmp);
return false;
}
return true;
}
}