#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Scripts.Monitoring * INGROUP: MokoStandards * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /monitoring/joomla-version-audit.php * VERSION: 01.00.00 * BRIEF: Audit Joomla core and extension versions across sites via the Joomla API */ declare(strict_types=1); final class JoomlaVersionAudit { /** @var array */ private array $args = []; /** @var array, error: string}> */ private array $results = []; public function run(): int { $this->args = $this->parseArgs(); $sites = $this->resolveSites(); if (count($sites) === 0) { $this->log('No sites provided. Use --sites .'); return 1; } $latestVersion = $this->args['latest'] ?? null; foreach ($sites as $site) { $this->log("Auditing: {$site['url']}"); $entry = $this->auditSite($site, $latestVersion); $this->results[] = $entry; } if (!empty($this->args['json'])) { $this->outputJson(); } else { $this->outputTable(); } $hasBehind = false; foreach ($this->results as $r) { if ($r['behind'] || $r['error'] !== '') { $hasBehind = true; break; } } return $hasBehind ? 1 : 0; } /** * @return array */ private function parseArgs(): array { $args = [ 'sites' => null, 'json' => false, 'latest' => null, ]; $argv = $_SERVER['argv'] ?? []; $argc = count($argv); for ($i = 1; $i < $argc; $i++) { switch ($argv[$i]) { case '--sites': $args['sites'] = $argv[++$i] ?? null; break; case '--json': $args['json'] = true; break; case '--latest': $args['latest'] = $argv[++$i] ?? null; break; } } return $args; } /** * @return list */ private function resolveSites(): array { $file = $this->args['sites'] ?? null; if (empty($file)) { return []; } if (!is_file($file) || !is_readable($file)) { $this->log("Cannot read sites file: {$file}"); return []; } $raw = file_get_contents($file); if ($raw === false) { $this->log("Failed to read sites file: {$file}"); return []; } $data = json_decode($raw, true); if (!is_array($data)) { $this->log("Sites file is not valid JSON: {$file}"); return []; } $sites = []; foreach ($data as $item) { if (is_array($item) && isset($item['url'], $item['token'])) { $sites[] = [ 'url' => rtrim((string) $item['url'], '/'), 'token' => (string) $item['token'], ]; } } return $sites; } /** * @param array{url: string, token: string} $site * @return array{url: string, joomlaVersion: string, behind: bool, extensions: array, error: string} */ private function auditSite(array $site, ?string $latestVersion): array { $entry = [ 'url' => $site['url'], 'joomlaVersion' => '', 'behind' => false, 'extensions' => [], 'error' => '', ]; // Fetch Joomla version from config/application endpoint $configData = $this->apiGet($site['url'] . '/api/index.php/v1/config/application', $site['token']); if ($configData === null) { $entry['error'] = 'Failed to fetch site configuration'; return $entry; } // The Joomla API returns config attributes; extract version from the response $joomlaVersion = $this->extractJoomlaVersion($configData); $entry['joomlaVersion'] = $joomlaVersion; // Compare against latest known version if ($latestVersion !== null && $joomlaVersion !== '') { if (version_compare($joomlaVersion, $latestVersion, '<')) { $entry['behind'] = true; } } // Fetch extensions list $extData = $this->apiGet($site['url'] . '/api/index.php/v1/extensions', $site['token']); if ($extData !== null && isset($extData['data']) && is_array($extData['data'])) { foreach ($extData['data'] as $ext) { $attrs = $ext['attributes'] ?? $ext; $name = $attrs['name'] ?? $attrs['element'] ?? 'unknown'; $version = $attrs['version'] ?? '?'; // Filter to meaningful extensions (components, modules, plugins, templates) $type = $attrs['type'] ?? ''; if (in_array($type, ['component', 'module', 'plugin', 'template', 'package', 'library'], true)) { $entry['extensions'][] = [ 'name' => (string) $name, 'version' => (string) $version, ]; } } } return $entry; } /** * @return array|null */ private function apiGet(string $url, string $token): ?array { $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'timeout' => 30, 'ignore_errors' => true, 'follow_location' => 1, 'max_redirects' => 3, 'header' => implode("\r\n", [ "X-Joomla-Token: {$token}", 'Accept: application/vnd.api+json', 'Content-Type: application/json', ]), ], 'ssl' => [ 'verify_peer' => true, 'verify_peer_name' => true, ], ]); $body = @file_get_contents($url, false, $context); if ($body === false) { $this->log(" API request failed: {$url}"); return null; } $data = json_decode($body, true); if (!is_array($data)) { $this->log(" Invalid JSON response from: {$url}"); return null; } return $data; } /** * @param array $configData */ private function extractJoomlaVersion(array $configData): string { // Joomla's Web Services API returns config data in a JSON:API structure. // The version may appear in data.attributes or in meta. if (isset($configData['meta']['cms-version'])) { return (string) $configData['meta']['cms-version']; } // Fallback: look through data attributes for a version-like field if (isset($configData['data']) && is_array($configData['data'])) { foreach ($configData['data'] as $item) { $attrs = $item['attributes'] ?? []; // Some Joomla API responses put the version in the attributes if (isset($attrs['cms-version'])) { return (string) $attrs['cms-version']; } } } // Second fallback: look for X-Powered-By or similar in response if (isset($configData['data']['attributes'])) { $attrs = $configData['data']['attributes']; foreach ($attrs as $key => $value) { if (stripos($key, 'version') !== false && is_string($value)) { return $value; } } } return 'unknown'; } private function outputTable(): void { $latestVersion = $this->args['latest'] ?? null; foreach ($this->results as $r) { $this->log(''); $this->log(str_repeat('=', 70)); $this->log("Site: {$r['url']}"); if ($r['error'] !== '') { $this->log(" Error: {$r['error']}"); continue; } $versionDisplay = $r['joomlaVersion']; if ($r['behind'] && $latestVersion !== null) { $versionDisplay .= " (BEHIND, latest: {$latestVersion})"; } $this->log(" Joomla Version: {$versionDisplay}"); if (count($r['extensions']) === 0) { $this->log(' Extensions: none found'); continue; } // Extension table $colName = 4; $colVersion = 7; foreach ($r['extensions'] as $ext) { $colName = max($colName, strlen($ext['name'])); $colVersion = max($colVersion, strlen($ext['version'])); } $colName = min($colName, 50); $colVersion = min($colVersion, 20); $fmt = " %-{$colName}s | %-{$colVersion}s"; $this->log(''); $this->log(sprintf($fmt, 'Name', 'Version')); $this->log(' ' . str_repeat('-', $colName + $colVersion + 4)); foreach ($r['extensions'] as $ext) { $nameDisplay = strlen($ext['name']) > 50 ? substr($ext['name'], 0, 47) . '...' : $ext['name']; $this->log(sprintf($fmt, $nameDisplay, $ext['version'])); } } $this->log(''); } private function outputJson(): void { echo json_encode($this->results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; } private function log(string $message): void { fwrite(STDERR, $message . "\n"); } } $audit = new JoomlaVersionAudit(); exit($audit->run());