Files
moko-platform/monitoring/joomla-version-audit.php
T
jmiller cf0b2726fd
Universal: Cascade Main → Dev / Cascade main → branches (push) Has been cancelled
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 / Access control (push) Has been cancelled
feat: add joomla-version-audit.php
Authored-by: Moko Consulting
2026-05-19 20:47:14 +00:00

331 lines
8.4 KiB
PHP

#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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<string, mixed> */
private array $args = [];
/** @var array<array{url: string, joomlaVersion: string, behind: bool, extensions: array<array{name: string, version: string}>, 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 <json-file>.');
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<string, mixed>
*/
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<array{url: string, token: string}>
*/
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<array{name: string, version: string}>, 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<string, mixed>|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<string, mixed> $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());