Skip to content

Commit 3a34431

Browse files
authored
Updates
1 parent 7aa259c commit 3a34431

2 files changed

Lines changed: 238 additions & 57 deletions

File tree

config/var/www/admin/control-panel/external-services/external-services-api.php

Lines changed: 221 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -79,42 +79,41 @@ function parseStatusFeed($feedUrl, $filter = null) {
7979
throw new Exception('Failed to fetch feed');
8080
}
8181

82-
// Suppress XML errors and parse securely
83-
libxml_use_internal_errors(true);
84-
// Disable external entity processing to prevent XXE attacks
85-
libxml_disable_entity_loader(true);
86-
// Parse XML without entity substitution (secure by default)
87-
// Do not use LIBXML_NOENT as it enables external entity substitution
88-
$xml = simplexml_load_string($feedContent, 'SimpleXMLElement');
89-
libxml_clear_errors();
90-
91-
if ($xml === false) {
92-
throw new Exception('Failed to parse XML');
93-
}
94-
95-
$status = [
96-
'indicator' => 'none',
97-
'description' => 'All Systems Operational'
98-
];
99-
100-
// Check if it's an Atom feed
101-
if (isset($xml->entry)) {
102-
// If filter provided, find matching entry
103-
$latestEntry = null;
104-
if ($filter !== null) {
82+
// Delegate to string parser for consistent behavior and testing
83+
return parseStatusFeedFromString($feedContent, $filter);
84+
// Choose the most recent relevant entry: prefer the newest matching filter if provided
85+
$latestEntry = null;
86+
$latestEntryTimestamp = 0;
10587
foreach ($xml->entry as $entry) {
10688
$entryTitle = isset($entry->title) ? (string)$entry->title : '';
107-
if (stripos($entryTitle, $filter) !== false) {
89+
if ($filter !== null && stripos($entryTitle, $filter) === false) {
90+
continue; // skip non-matching entries
91+
}
92+
93+
// Determine entry timestamp preference: updated > published
94+
$entryDateCandidate = null;
95+
if (isset($entry->updated)) {
96+
$entryDateCandidate = strtotime((string)$entry->updated);
97+
} elseif (isset($entry->published)) {
98+
$entryDateCandidate = strtotime((string)$entry->published);
99+
}
100+
101+
// Prefer entries with a valid timestamp; if none have timestamps, fallback to first matching entry
102+
$timestampValue = $entryDateCandidate ? $entryDateCandidate : 0;
103+
if ($timestampValue > $latestEntryTimestamp) {
104+
$latestEntryTimestamp = $timestampValue;
108105
$latestEntry = $entry;
109-
break;
106+
} elseif ($latestEntry === null && $filter === null) {
107+
// When no filter, ensure at least the first entry is selected even without timestamps
108+
$latestEntry = $xml->entry[0];
110109
}
111110
}
112-
// If no matching entry, return operational
113-
if ($latestEntry === null) {
111+
// If no matching entry found (when filter used), return operational
112+
if ($filter !== null && $latestEntry === null) {
114113
return $status;
115114
}
116-
}
117-
$latestEntry = $latestEntry ?? $xml->entry[0];
115+
// If we couldn't find a timestamped entry and latestEntry is still null, fallback to first entry
116+
$latestEntry = $latestEntry ?? $xml->entry[0];
118117

119118
$title = isset($latestEntry->title) ? (string)$latestEntry->title : '';
120119
$content = isset($latestEntry->content) ? (string)$latestEntry->content : '';
@@ -125,7 +124,7 @@ function parseStatusFeed($feedUrl, $filter = null) {
125124
$content = preg_replace('/<!\[CDATA\[(.*?)\]\]>/s', '$1', $content);
126125
$summary = preg_replace('/<!\[CDATA\[(.*?)\]\]>/s', '$1', $summary);
127126

128-
// Get entry timestamp
127+
// Get entry timestamp (prefer updated -> published)
129128
$entryDate = null;
130129
if (isset($latestEntry->updated)) {
131130
$entryDate = strtotime((string)$latestEntry->updated);
@@ -139,8 +138,9 @@ function parseStatusFeed($feedUrl, $filter = null) {
139138
// For Brevo and similar feeds, prefer title only
140139
$description = !empty($title) ? $title : (!empty($content) ? $content : $summary);
141140

142-
// Only show status if entry is within 24 hours, otherwise show operational
143-
if (!$isRecent || preg_match('/operational|resolved|completed|fixed|normal/i', $title)) {
141+
// Only show status if entry is within 24 hours and not resolved; otherwise show operational
142+
$fullText = $title . ' ' . $content . ' ' . $summary;
143+
if (!$isRecent || preg_match('/operational|resolved|completed|fixed|normal/i', $fullText)) {
144144
$status['indicator'] = 'none';
145145
$status['description'] = 'All Systems Operational';
146146
} elseif (preg_match('/outage|down|major|critical|offline/i', $title)) {
@@ -155,30 +155,46 @@ function parseStatusFeed($feedUrl, $filter = null) {
155155
if ($status['indicator'] !== 'none' && $status['indicator'] !== 'major' && $status['indicator'] !== 'minor') {
156156
$status['description'] = sanitizeFeedText($title);
157157
}
158-
}
158+
159159
// Check if it's an RSS feed
160160
elseif (isset($xml->channel->item)) {
161-
// If filter provided, find matching item
161+
// Choose the most recent relevant item: prefer the newest matching filter if provided
162162
$latestItem = null;
163-
if ($filter !== null) {
164-
foreach ($xml->channel->item as $item) {
165-
$itemTitle = isset($item->title) ? (string)$item->title : '';
166-
if (stripos($itemTitle, $filter) !== false) {
167-
$latestItem = $item;
168-
break;
169-
}
163+
$latestItemTimestamp = 0;
164+
foreach ($xml->channel->item as $item) {
165+
$itemTitle = isset($item->title) ? (string)$item->title : '';
166+
if ($filter !== null && stripos($itemTitle, $filter) === false) {
167+
continue; // skip non-matching items
170168
}
171-
// If no matching item, return operational
172-
if ($latestItem === null) {
173-
return $status;
169+
170+
// Determine item timestamp preference: pubDate > dc:date
171+
$itemDateCandidate = null;
172+
if (isset($item->pubDate)) {
173+
$itemDateCandidate = strtotime((string)$item->pubDate);
174+
} elseif (isset($item->children('http://purl.org/dc/elements/1.1/')->date)) {
175+
$itemDateCandidate = strtotime((string)$item->children('http://purl.org/dc/elements/1.1/')->date);
174176
}
177+
178+
$timestampValue = $itemDateCandidate ? $itemDateCandidate : 0;
179+
if ($timestampValue > $latestItemTimestamp) {
180+
$latestItemTimestamp = $timestampValue;
181+
$latestItem = $item;
182+
} elseif ($latestItem === null && $filter === null) {
183+
// When no filter, ensure at least the first item is selected even without timestamps
184+
$latestItem = $xml->channel->item[0];
185+
}
186+
}
187+
// If no matching item found (when filter used), return operational
188+
if ($filter !== null && $latestItem === null) {
189+
return $status;
175190
}
191+
// Fallback to first item if none selected
176192
$latestItem = $latestItem ?? $xml->channel->item[0];
177193

178194
$title = isset($latestItem->title) ? (string)$latestItem->title : '';
179195
$description = isset($latestItem->description) ? (string)$latestItem->description : '';
180196

181-
// Get item timestamp
197+
// Get item timestamp (prefer pubDate -> dc:date)
182198
$itemDate = null;
183199
if (isset($latestItem->pubDate)) {
184200
$itemDate = strtotime((string)$latestItem->pubDate);
@@ -189,8 +205,9 @@ function parseStatusFeed($feedUrl, $filter = null) {
189205
// Check if item is within 24 hours (86400 seconds)
190206
$isRecent = ($itemDate && (time() - $itemDate) <= 86400);
191207

192-
// Only show status if item is within 24 hours, otherwise show operational
193-
if (!$isRecent || preg_match('/operational|resolved|completed|fixed|normal/i', $title)) {
208+
// Only show status if item is within 24 hours and not resolved; otherwise show operational
209+
$fullText = $title . ' ' . $description;
210+
if (!$isRecent || preg_match('/operational|resolved|completed|fixed|normal/i', $fullText)) {
194211
$status['indicator'] = 'none';
195212
$status['description'] = 'All Systems Operational';
196213
} elseif (preg_match('/outage|down|major|critical|offline/i', $title)) {
@@ -220,6 +237,163 @@ function parseStatusFeed($feedUrl, $filter = null) {
220237
}
221238
}
222239

240+
/**
241+
* Parse feed content directly from XML string (for testing/validation)
242+
*/
243+
function parseStatusFeedFromString($feedContent, $filter = null) {
244+
try {
245+
libxml_use_internal_errors(true);
246+
$xml = simplexml_load_string($feedContent, 'SimpleXMLElement');
247+
libxml_clear_errors();
248+
if ($xml === false) {
249+
throw new Exception('Failed to parse XML');
250+
}
251+
252+
// Use the same parsing branch as parseStatusFeed
253+
return parseStatusFeedFromXmlObject($xml, $filter);
254+
} catch (Exception $e) {
255+
return [
256+
'indicator' => 'major',
257+
'description' => 'Unable to fetch status'
258+
];
259+
}
260+
}
261+
262+
/**
263+
* Core parsing logic extracted to separate function to avoid duplication
264+
*/
265+
function parseStatusFeedFromXmlObject($xml, $filter = null) {
266+
$status = [
267+
'indicator' => 'none',
268+
'description' => 'All Systems Operational'
269+
];
270+
271+
// Atom feed handling
272+
if (isset($xml->entry)) {
273+
// Choose most recent matching entry
274+
$latestEntry = null;
275+
$latestEntryTimestamp = 0;
276+
foreach ($xml->entry as $entry) {
277+
$entryTitle = isset($entry->title) ? (string)$entry->title : '';
278+
if ($filter !== null && stripos($entryTitle, $filter) === false) {
279+
continue;
280+
}
281+
$entryDateCandidate = null;
282+
if (isset($entry->updated)) {
283+
$entryDateCandidate = strtotime((string)$entry->updated);
284+
} elseif (isset($entry->published)) {
285+
$entryDateCandidate = strtotime((string)$entry->published);
286+
}
287+
$timestampValue = $entryDateCandidate ? $entryDateCandidate : 0;
288+
if ($timestampValue > $latestEntryTimestamp) {
289+
$latestEntryTimestamp = $timestampValue;
290+
$latestEntry = $entry;
291+
}
292+
}
293+
if ($filter !== null && $latestEntry === null) {
294+
return $status;
295+
}
296+
$latestEntry = $latestEntry ?? $xml->entry[0];
297+
298+
$title = isset($latestEntry->title) ? (string)$latestEntry->title : '';
299+
$content = isset($latestEntry->content) ? (string)$latestEntry->content : '';
300+
$summary = isset($latestEntry->summary) ? (string)$latestEntry->summary : '';
301+
302+
$title = preg_replace('/<!\[CDATA\[(.*?)\]\]>/s', '$1', $title);
303+
$content = preg_replace('/<!\[CDATA\[(.*?)\]\]>/s', '$1', $content);
304+
$summary = preg_replace('/<!\[CDATA\[(.*?)\]\]>/s', '$1', $summary);
305+
306+
$entryDate = null;
307+
if (isset($latestEntry->updated)) {
308+
$entryDate = strtotime((string)$latestEntry->updated);
309+
} elseif (isset($latestEntry->published)) {
310+
$entryDate = strtotime((string)$latestEntry->published);
311+
}
312+
313+
$isRecent = ($entryDate && (time() - $entryDate) <= 86400);
314+
315+
$description = !empty($title) ? $title : (!empty($content) ? $content : $summary);
316+
if (!$isRecent || preg_match('/operational|resolved|completed|fixed|normal/i', $title)) {
317+
$status['indicator'] = 'none';
318+
$status['description'] = 'All Systems Operational';
319+
} elseif (preg_match('/outage|down|major|critical|offline/i', $title)) {
320+
$status['indicator'] = 'major';
321+
$status['description'] = sanitizeFeedText($description);
322+
} elseif (preg_match('/degraded|issue|problem|investigating|identified|monitoring/i', $title)) {
323+
$status['indicator'] = 'minor';
324+
$status['description'] = sanitizeFeedText($description);
325+
}
326+
327+
if ($status['indicator'] !== 'none' && $status['indicator'] !== 'major' && $status['indicator'] !== 'minor') {
328+
$status['description'] = sanitizeFeedText($title);
329+
}
330+
if (strlen($status['description']) > 200) {
331+
$status['description'] = substr($status['description'], 0, 197) . '...';
332+
}
333+
return $status;
334+
}
335+
336+
// RSS feed handling (channel->item)
337+
if (isset($xml->channel->item)) {
338+
$latestItem = null;
339+
$latestItemTimestamp = 0;
340+
foreach ($xml->channel->item as $item) {
341+
$itemTitle = isset($item->title) ? (string)$item->title : '';
342+
if ($filter !== null && stripos($itemTitle, $filter) === false) {
343+
continue;
344+
}
345+
$itemDateCandidate = null;
346+
if (isset($item->pubDate)) {
347+
$itemDateCandidate = strtotime((string)$item->pubDate);
348+
} elseif (isset($item->children('http://purl.org/dc/elements/1.1/')->date)) {
349+
$itemDateCandidate = strtotime((string)$item->children('http://purl.org/dc/elements/1.1/')->date);
350+
}
351+
$timestampValue = $itemDateCandidate ? $itemDateCandidate : 0;
352+
if ($timestampValue > $latestItemTimestamp) {
353+
$latestItemTimestamp = $timestampValue;
354+
$latestItem = $item;
355+
}
356+
}
357+
if ($filter !== null && $latestItem === null) {
358+
return $status;
359+
}
360+
$latestItem = $latestItem ?? $xml->channel->item[0];
361+
362+
$title = isset($latestItem->title) ? (string)$latestItem->title : '';
363+
$description = isset($latestItem->description) ? (string)$latestItem->description : '';
364+
365+
$itemDate = null;
366+
if (isset($latestItem->pubDate)) {
367+
$itemDate = strtotime((string)$latestItem->pubDate);
368+
} elseif (isset($latestItem->children('http://purl.org/dc/elements/1.1/')->date)) {
369+
$itemDate = strtotime((string)$latestItem->children('http://purl.org/dc/elements/1.1/')->date);
370+
}
371+
372+
$isRecent = ($itemDate && (time() - $itemDate) <= 86400);
373+
374+
if (!$isRecent || preg_match('/operational|resolved|completed|fixed|normal/i', $title)) {
375+
$status['indicator'] = 'none';
376+
$status['description'] = 'All Systems Operational';
377+
} elseif (preg_match('/outage|down|major|critical|offline/i', $title)) {
378+
$status['indicator'] = 'major';
379+
$status['description'] = sanitizeFeedText(!empty($description) ? $description : $title);
380+
} elseif (preg_match('/degraded|issue|problem|investigating|identified|monitoring/i', $title)) {
381+
$status['indicator'] = 'minor';
382+
$status['description'] = sanitizeFeedText(!empty($description) ? $description : $title);
383+
}
384+
if (!preg_match('/operational|resolved|completed|fixed|normal|outage|down|major|critical|offline|degraded|issue|problem|investigating|identified|monitoring/i', $title)) {
385+
$status['description'] = sanitizeFeedText($title);
386+
}
387+
if (strlen($status['description']) > 200) {
388+
$status['description'] = substr($status['description'], 0, 197) . '...';
389+
}
390+
return $status;
391+
}
392+
393+
// Default if not Atom or RSS
394+
return $status;
395+
}
396+
223397
/**
224398
* Parse Google Workspace incidents JSON API
225399
*/

config/var/www/admin/control-panel/external-services/external-services.js

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,19 @@ export class ExternalServicesManager {
536536
return data;
537537
}
538538

539+
/**
540+
* Get service card DOM element for a given key and log if not found
541+
*/
542+
getServiceCardElement(serviceKey, serviceDef) {
543+
const serviceCard = document.querySelector(`[data-service-key="${serviceKey}"]`);
544+
if (!serviceCard) {
545+
const name = serviceDef && serviceDef.name ? serviceDef.name : serviceKey;
546+
console.error(`Card not found for service: ${serviceKey} (${name})`);
547+
return null;
548+
}
549+
return serviceCard;
550+
}
551+
539552
/**
540553
* Update feed-based service status asynchronously
541554
*/
@@ -558,11 +571,8 @@ export class ExternalServicesManager {
558571
}
559572

560573
// Find the card and update it
561-
const serviceCard = document.querySelector(`[data-service-key="${serviceKey}"]`);
562-
if (!serviceCard) {
563-
console.error(`Card not found for service: ${serviceKey}`);
564-
return;
565-
}
574+
const serviceCard = this.getServiceCardElement(serviceKey, serviceDef);
575+
if (!serviceCard) return;
566576

567577
const { statusClass, statusIcon, statusColor } = this.getStatusDisplayValues(data.status.indicator, true);
568578
this.updateServiceCardStatus(serviceCard, data.status.description, statusClass, statusIcon, statusColor);
@@ -588,11 +598,8 @@ export class ExternalServicesManager {
588598
}
589599

590600
// Find the card and update it
591-
const serviceCard = document.querySelector(`[data-service-key="${serviceKey}"]`);
592-
if (!serviceCard) {
593-
console.error(`Card not found for service: ${serviceKey}`);
594-
return;
595-
}
601+
const serviceCard = this.getServiceCardElement(serviceKey, serviceDef);
602+
if (!serviceCard) return;
596603

597604
const { statusClass, statusIcon, statusColor } = this.getStatusDisplayValues(data.status.indicator, false);
598605
this.updateServiceCardStatus(serviceCard, data.status.description, statusClass, statusIcon, statusColor);

0 commit comments

Comments
 (0)